diff --git a/src/backend/base/langflow/api/v1/deployments.py b/src/backend/base/langflow/api/v1/deployments.py
index 8d0e2e96ad..54c8ca3db1 100644
--- a/src/backend/base/langflow/api/v1/deployments.py
+++ b/src/backend/base/langflow/api/v1/deployments.py
@@ -31,8 +31,6 @@ from langflow.api.v1.mappers.deployments.helpers import (
handle_adapter_errors,
list_deployment_flow_versions_synced,
list_deployments_synced,
- normalize_flow_ids_query,
- normalize_flow_version_query_ids,
page_offset,
raise_http_for_value_error,
resolve_adapter_from_deployment,
@@ -41,12 +39,10 @@ from langflow.api.v1.mappers.deployments.helpers import (
resolve_deployment_adapter,
resolve_flow_version_patch_for_update,
resolve_project_id_for_deployment_create,
- resolve_provider_tenant_id,
resolve_snapshot_map_for_create,
rollback_provider_create,
rollback_provider_update,
sync_attachment_snapshot_ids,
- to_provider_account_response,
validate_project_scoped_flow_version_ids,
)
from langflow.api.v1.schemas.deployments import (
@@ -91,7 +87,7 @@ from langflow.services.database.models.deployment_provider_account.crud import (
count_provider_accounts as count_provider_account_rows,
)
from langflow.services.database.models.deployment_provider_account.crud import (
- create_provider_account as create_provider_account_row,
+ create_provider_account_from_model as create_provider_account_row,
)
from langflow.services.database.models.deployment_provider_account.crud import (
delete_provider_account as delete_provider_account_row,
@@ -258,31 +254,24 @@ async def create_provider_account(
deployment_adapter = resolve_deployment_adapter(payload.provider_key)
with handle_adapter_errors(mapper=deployment_mapper):
- verify_input = deployment_mapper.resolve_verify_credentials(payload=payload)
+ verify_input = deployment_mapper.resolve_verify_credentials_for_create(payload=payload)
await deployment_adapter.verify_credentials(
user_id=current_user.id,
payload=verify_input,
)
try:
- resolved_provider_tenant_id = resolve_provider_tenant_id(
- deployment_mapper=deployment_mapper,
- provider_url=payload.url,
- provider_data=payload.provider_data,
+ provider_account_to_create = deployment_mapper.resolve_provider_account_create(
+ payload=payload,
+ user_id=current_user.id,
)
- credential_kwargs = deployment_mapper.resolve_credential_fields(provider_data=payload.provider_data)
provider_account = await create_provider_account_row(
session,
- user_id=current_user.id,
- name=payload.name,
- provider_tenant_id=resolved_provider_tenant_id,
- provider_key=payload.provider_key,
- provider_url=payload.url,
- **credential_kwargs,
+ provider_account=provider_account_to_create,
)
except ValueError as exc:
_raise_http_for_provider_account_value_error(exc)
- return to_provider_account_response(provider_account)
+ return deployment_mapper.resolve_provider_account_response(provider_account)
@router.get("/providers", response_model=DeploymentProviderAccountListResponse, tags=["Deployment Providers"])
@@ -296,7 +285,10 @@ async def list_provider_accounts(
provider_accounts = await list_provider_account_rows(session, user_id=current_user.id, offset=offset, limit=size)
total = await count_provider_account_rows(session, user_id=current_user.id)
return DeploymentProviderAccountListResponse(
- provider_accounts=[to_provider_account_response(item) for item in provider_accounts],
+ provider_accounts=[
+ get_deployment_mapper(item.provider_key).resolve_provider_account_response(item)
+ for item in provider_accounts
+ ],
page=page,
size=size,
total=total,
@@ -316,7 +308,7 @@ async def get_provider_account(
provider_account = await get_provider_account_row_by_id(session, provider_id=provider_id, user_id=current_user.id)
if provider_account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deployment provider account not found.")
- return to_provider_account_response(provider_account)
+ return get_deployment_mapper(provider_account.provider_key).resolve_provider_account_response(provider_account)
@router.delete(
@@ -403,7 +395,7 @@ async def update_provider_account(
)
except ValueError as exc:
_raise_http_for_provider_account_value_error(exc)
- return to_provider_account_response(updated)
+ return deployment_mapper.resolve_provider_account_response(updated)
@router.post("", response_model=DeploymentCreateResponse, status_code=status.HTTP_201_CREATED)
@@ -607,30 +599,29 @@ async def list_deployments(
),
] = None,
):
- normalized_flow_version_ids = normalize_flow_version_query_ids(flow_version_ids)
- normalized_flow_ids = normalize_flow_ids_query(flow_ids)
- if normalized_flow_ids and normalized_flow_version_ids:
+ if flow_ids and flow_version_ids:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="flow_ids and flow_version_ids are mutually exclusive.",
)
- if load_from_provider and normalized_flow_version_ids:
+ if load_from_provider and flow_version_ids:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
- detail="flow_version_ids filtering is not supported when load_from_provider=true.",
+ detail="flow_version_ids filtering is not supported when loading deployments directly from the provider.",
)
- if load_from_provider and normalized_flow_ids:
+ if load_from_provider and flow_ids:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
- detail="flow_ids filtering is not supported when load_from_provider=true.",
+ detail="flow_ids filtering is not supported when loading deployments directly from the provider.",
)
- if normalized_flow_ids:
- resolved = await flow_version_ids_for_flows(session, flow_ids=normalized_flow_ids, user_id=current_user.id)
+ effective_flow_version_ids = flow_version_ids
+ if flow_ids:
+ resolved = await flow_version_ids_for_flows(session, flow_ids=flow_ids, user_id=current_user.id)
if not resolved:
return DeploymentListResponse(
deployments=[], page=params.page, size=params.size, total=0, deployment_type=deployment_type
)
- normalized_flow_version_ids = resolved
+ effective_flow_version_ids = resolved
provider_account = await get_owned_provider_account_or_404(
provider_id=provider_id, user_id=current_user.id, db=session
)
@@ -655,11 +646,14 @@ async def list_deployments(
page=params.page,
size=params.size,
deployment_type=deployment_type,
- flow_version_ids=normalized_flow_version_ids or None,
+ flow_version_ids=effective_flow_version_ids,
)
deployments = deployment_mapper.shape_deployment_list_items(
rows_with_counts=rows_with_counts,
- has_flow_filter=bool(normalized_flow_version_ids),
+ # include flow_version_ids in list items only when
+ # flow_version_ids or flow_ids filtering is active.
+ # (empty lists are rejected by validation)
+ has_flow_filter=bool(flow_version_ids or flow_ids),
provider_key=provider_account.provider_key,
)
return DeploymentListResponse(
@@ -1354,7 +1348,6 @@ async def list_deployment_flow_versions(
),
] = None,
):
- normalized_flow_ids = normalize_flow_ids_query(flow_ids)
deployment_row, deployment_adapter, deployment_mapper, _provider_key = await resolve_adapter_mapper_from_deployment(
deployment_id=deployment_id,
user_id=current_user.id,
@@ -1373,7 +1366,7 @@ async def list_deployment_flow_versions(
db=session,
page=page,
size=size,
- flow_ids=normalized_flow_ids or None,
+ flow_ids=flow_ids,
)
return deployment_mapper.shape_flow_version_list_result(
rows=rows,
diff --git a/src/backend/base/langflow/api/v1/mappers/deployments/RULES.md b/src/backend/base/langflow/api/v1/mappers/deployments/RULES.md
index 1429836360..69153a4da3 100644
--- a/src/backend/base/langflow/api/v1/mappers/deployments/RULES.md
+++ b/src/backend/base/langflow/api/v1/mappers/deployments/RULES.md
@@ -37,7 +37,7 @@ Not allowed in routes:
The mapper translates API payloads to Adapters and the Langflow DB:
- **API → Adapter** — reshapes the API request's `provider_data` into adapter-layer input models (e.g. `VerifyCredentials`, `AdapterDeploymentCreate`). The adapter then makes the actual provider SDK/network calls.
-- **API → DB** — extracts provider-specific fields from the API request and returns a flat `dict[str, Any]` of DB column-value pairs that the route spreads into CRUD kwargs (e.g. `resolve_credential_fields` returns `{"api_key": "..."}`, `resolve_provider_account_update` returns the full update diff).
+- **API → DB** — extracts provider-specific fields from the API request and assembles typed DB-bound create/update contracts (e.g. `resolve_provider_account_create` returns a `DeploymentProviderAccount` model and `resolve_provider_account_update` returns the full update diff dict).
Mapper responsibility includes:
@@ -293,19 +293,25 @@ The mapper is the **single** component that understands a provider's credential
**Credential flow (API → DB):**
- The API schema exposes credentials as an opaque `provider_data: dict[str, Any]`. It does not validate the dict's contents.
-- The mapper's `resolve_credential_fields(provider_data=...)` validates, extracts, and returns a `dict[str, Any]` of DB column-value pairs (e.g. `{"api_key": "..."}` for WXO today). The route spreads these into the CRUD layer's keyword arguments.
+- The mapper's `resolve_credentials(provider_data=...)` validates and extracts credential DB fields (e.g. `{"api_key": "..."}` for WXO today). Mapper create/update assemblers own how these fields are applied.
- The DB model keeps a fixed column set (currently `api_key: str`). If a future provider requires a different storage layout (multiple columns, a serialised JSON blob, etc.), only the mapper and CRUD layer need to evolve — the route and schema remain unchanged.
+**Create assembly (API → DB):**
+
+- The mapper's `resolve_provider_account_create(payload=..., user_id=...)` assembles the complete provider-account create model for CRUD, including provider URL, tenant/account identifiers, and credential fields.
+- The base mapper does not implement provider-account create assembly; provider mappers must implement `resolve_provider_account_create(...)`.
+- Routes must not manually compose provider-specific create kwargs from `provider_data`; they delegate create assembly to the mapper.
+
**Update assembly (API → DB):**
- The mapper's `resolve_provider_account_update(payload=..., existing_account=...)` assembles the complete update kwargs dict. Only fields present in `payload.model_fields_set` are included so the CRUD layer receives a minimal diff.
- Provider mappers override this method to add provider-specific update logic. Provider-account updates currently allow changing display name and credentials only; URL/tenant identifiers must remain immutable after create.
- The base mapper provides a concrete default that handles common mutable fields (display name + credentials). Provider overrides call `super()` for the common fields and only add their own cross-field rules.
-**Defense-in-depth (DB model validator):**
+**DB model validator:**
- The `DeploymentProviderAccount` model has a `model_validator` that calls `validate_tenant_url_consistency()`. This catches inconsistent tenant/URL pairs regardless of entry point — even if a future code path bypasses the mapper.
-- The validation logic lives in `deployment_provider_account/utils.py` as the single source of truth. Both the model validator and the WXO mapper's `resolve_provider_tenant_id` delegate to the same `extract_tenant_from_url()` function.
+- The validation logic lives in `deployment_provider_account/utils.py` as the single source of truth. Both the model validator and the WXO create-path mapper logic delegate to the same `extract_tenant_from_url()` function.
- Provider metadata such as tenant/account identifiers should arrive via `provider_data`; mappers extract and normalize them before persistence.
---
@@ -409,10 +415,9 @@ Use this checklist before merge:
- [ ] Mapper boundary result signatures are not over-narrowed to dict-only generics without contract guarantees
- [ ] Method names follow semantic families (`resolve_*`, `shape_*`, `util_*`)
- [ ] Registry/contracts/base files remain separated by purpose
-- [ ] Provider-account update logic lives in mapper, not in route conditionals
+- [ ] Provider-account create/update logic lives in mapper, not in route conditionals
- [ ] Provider-specific cross-field rules (e.g. tenant/URL coupling) are implemented as mapper overrides calling `super()`, not as base-class conditionals
-- [ ] Credential extraction uses `resolve_credential_fields`, not route-level assumptions about `provider_data` contents
-- [ ] Non-persisted provider-originated fields are placed in `provider_data` (never top-level)
+- [ ] Credential extraction uses `resolve_credentials`, not route-level assumptions about `provider_data` contents
- [ ] DB-level consistency validators exist as defense-in-depth for cross-field invariants
- [ ] Tests cover both base mapper defaults and provider overrides
- [ ] Failure cases for missing/unexpected bindings are covered
diff --git a/src/backend/base/langflow/api/v1/mappers/deployments/base.py b/src/backend/base/langflow/api/v1/mappers/deployments/base.py
index 92855ad863..2e5caebc7d 100644
--- a/src/backend/base/langflow/api/v1/mappers/deployments/base.py
+++ b/src/backend/base/langflow/api/v1/mappers/deployments/base.py
@@ -6,14 +6,15 @@ Provider-account credential contract
Provider credentials arrive in the API request as an opaque
``provider_data: dict`` and leave via two mapper methods:
-* **API -> Adapter** (``resolve_verify_credentials``): packs the request's
- ``provider_data`` into the adapter-layer ``VerifyCredentials`` model so the
- deployment adapter can validate the credentials against the provider.
+* **API -> Adapter** (``resolve_verify_credentials_for_create``): packs the
+ request's ``provider_data`` into the adapter-layer ``VerifyCredentials``
+ model so the deployment adapter can validate the credentials against the
+ provider.
-* **API -> DB** (``resolve_credential_fields``): extracts credentials from
- ``provider_data`` and returns a ``dict[str, Any]`` of DB column-value
- pairs (e.g. ``{"api_key": "..."}``). The route spreads these into the
- CRUD layer's keyword arguments.
+* **API -> DB** (``resolve_credentials``): extracts credentials from
+ ``provider_data`` and returns DB column-value pairs
+ (e.g. ``{"api_key": "..."}``) used by mapper-owned create/update
+ assembly methods.
The mapper is the **single** component that understands a provider's
credential shape. The API schema treats ``provider_data`` as opaque and
@@ -107,6 +108,10 @@ class DeploymentApiPayloads(DeploymentPayloadFields):
slot population is defined separately via ``DeploymentPayloadSchemas``.
"""
+ provider_account_create: PayloadSlot | None = None
+ provider_account_update: PayloadSlot | None = None
+ provider_account_response: PayloadSlot | None = None
+
class BaseDeploymentMapper:
"""Per-provider mapper for deployment API payloads.
@@ -395,26 +400,17 @@ class BaseDeploymentMapper:
provider_data=provider_data,
)
- def resolve_provider_tenant_id(
+ def validate_create_provider_url(
self,
*,
- provider_url: str,
provider_data: dict[str, Any],
- ) -> str | None:
- """Resolve provider tenant id for provider-account create/update."""
- _ = provider_url
- return self.resolve_provider_tenant_id_from_data(provider_data=provider_data)
+ ) -> str:
+ """Resolve and validate provider URL from create provider_data.
- def resolve_provider_tenant_id_from_data(self, *, provider_data: dict[str, Any]) -> str | None:
- """Extract optional tenant/account identifier from provider_data."""
- raw_tenant_id = provider_data.get("tenant_id")
- if raw_tenant_id is None:
- return None
- if not isinstance(raw_tenant_id, str):
- msg = "provider_data.tenant_id must be a string when provided."
- raise ValueError(msg) # noqa: TRY004 - route layer maps ValueError to HTTP 4xx
- tenant_id = raw_tenant_id.strip()
- return tenant_id or None
+ Provider mappers must override this for provider-account create.
+ """
+ _ = provider_data
+ raise NotImplementedError
def format_conflict_detail(self, raw_message: str) -> str:
"""Format provider conflict errors for API responses.
@@ -424,7 +420,7 @@ class BaseDeploymentMapper:
"""
return f"A resource with this name already exists in the provider. {raw_message}"
- def resolve_credential_fields(
+ def resolve_credentials(
self,
*,
provider_data: dict[str, Any],
@@ -438,6 +434,20 @@ class BaseDeploymentMapper:
"""
raise NotImplementedError
+ def resolve_provider_account_create(
+ self,
+ *,
+ payload: DeploymentProviderAccountCreateRequest,
+ user_id: UUID,
+ ) -> DeploymentProviderAccount:
+ """Assemble provider-account DB model for create.
+
+ Provider mappers must override this so provider-specific create
+ semantics stay out of the base mapper.
+ """
+ _ = (payload, user_id)
+ raise NotImplementedError
+
def resolve_provider_account_update(
self,
*,
@@ -459,23 +469,22 @@ class BaseDeploymentMapper:
if payload.provider_data is None:
msg = "'provider_data' cannot be null when provided."
raise ValueError(msg)
- update_kwargs.update(self.resolve_credential_fields(provider_data=payload.provider_data))
+ update_kwargs.update(self.resolve_credentials(provider_data=payload.provider_data))
return update_kwargs
- def resolve_verify_credentials(
+ def resolve_verify_credentials_for_create(
self,
*,
payload: DeploymentProviderAccountCreateRequest,
) -> VerifyCredentials:
- """Build adapter verify-credentials input from the API create request.
+ """Build adapter verify-credentials input from create payload.
- The base implementation extracts only ``base_url``. Credentials
- are provider-specific and must be packed into ``provider_data`` by
- provider mapper overrides.
+ The base implementation extracts ``base_url`` from
+ ``provider_data.url``. Credentials are provider-specific and must be
+ packed into ``provider_data`` by provider mapper overrides.
"""
- return VerifyCredentials(
- base_url=payload.url,
- )
+ _ = payload
+ raise NotImplementedError
def resolve_verify_credentials_for_update(
self,
@@ -495,7 +504,7 @@ class BaseDeploymentMapper:
msg = "Credential verification for provider account updates is not implemented for this provider."
raise NotImplementedError(msg)
- def shape_provider_account_response(
+ def resolve_provider_account_response(
self,
provider_account: DeploymentProviderAccount,
) -> DeploymentProviderAccountGetResponse:
@@ -503,24 +512,17 @@ class BaseDeploymentMapper:
id=provider_account.id,
name=provider_account.name,
provider_key=provider_account.provider_key,
- url=provider_account.provider_url,
- provider_data=self.shape_provider_account_provider_data(provider_account),
+ provider_data=self.resolve_provider_account_provider_data(provider_account),
created_at=provider_account.created_at,
updated_at=provider_account.updated_at,
)
- def shape_provider_account_provider_data(
+ def resolve_provider_account_provider_data(
self,
provider_account: DeploymentProviderAccount,
) -> dict[str, Any] | None:
"""Return non-sensitive provider metadata for provider-account responses."""
- raw_tenant_id = provider_account.provider_tenant_id
- if raw_tenant_id is None:
- return None
- tenant_id = str(raw_tenant_id).strip()
- if not tenant_id:
- return None
- return {"tenant_id": tenant_id}
+ return {"url": provider_account.provider_url}
def util_create_flow_artifact_provider_data(
self,
@@ -546,7 +548,7 @@ class BaseDeploymentMapper:
) -> str | None:
"""Return provider deployment id to reuse on create, if requested."""
_ = payload
- return None
+ raise NotImplementedError
def util_should_mutate_provider_for_existing_deployment_create(
self,
@@ -554,7 +556,7 @@ class BaseDeploymentMapper:
) -> bool:
"""Return whether existing-resource create should call provider update."""
_ = payload
- return True
+ raise NotImplementedError
def util_create_result_from_existing_update(
self,
@@ -567,11 +569,8 @@ class BaseDeploymentMapper:
Routes use this when create-time onboarding reuses an existing provider
resource and mutates it through ``adapter.update``.
"""
- provider_result = result.provider_result if isinstance(result.provider_result, dict) else None
- return DeploymentCreateResult(
- id=existing_resource_key,
- provider_result=provider_result,
- )
+ _ = (existing_resource_key, result)
+ raise NotImplementedError
def util_create_result_from_existing_resource(
self,
diff --git a/src/backend/base/langflow/api/v1/mappers/deployments/helpers.py b/src/backend/base/langflow/api/v1/mappers/deployments/helpers.py
index a269686d91..1876a6aa6d 100644
--- a/src/backend/base/langflow/api/v1/mappers/deployments/helpers.py
+++ b/src/backend/base/langflow/api/v1/mappers/deployments/helpers.py
@@ -33,7 +33,6 @@ from sqlmodel import col, func, select
from langflow.api.v1.schemas.deployments import (
DeploymentCreateRequest,
- DeploymentProviderAccountGetResponse,
DeploymentUpdateRequest,
)
from langflow.initial_setup.setup import get_or_create_default_folder
@@ -296,15 +295,6 @@ def page_offset(page: int, size: int) -> int:
return (page - 1) * size
-def as_uuid(value: UUID | str) -> UUID | None:
- if isinstance(value, UUID):
- return value
- try:
- return UUID(str(value))
- except (TypeError, ValueError):
- return None
-
-
def raise_http_for_value_error(exc: ValueError) -> None:
status_code = status.HTTP_404_NOT_FOUND if "not found" in str(exc).lower() else status.HTTP_400_BAD_REQUEST
raise HTTPException(status_code=status_code, detail=str(exc)) from exc
@@ -351,53 +341,6 @@ def handle_adapter_errors(*, mapper: BaseDeploymentMapper | None = None):
) from exc
-def resolve_provider_tenant_id(
- *,
- deployment_mapper: BaseDeploymentMapper,
- provider_url: str,
- provider_data: dict[str, Any],
-) -> str | None:
- return deployment_mapper.resolve_provider_tenant_id(
- provider_url=provider_url,
- provider_data=provider_data,
- )
-
-
-def to_provider_account_response(provider_account: DeploymentProviderAccount) -> DeploymentProviderAccountGetResponse:
- from langflow.api.v1.mappers.deployments.registry import get_deployment_mapper
-
- deployment_mapper = get_deployment_mapper(provider_account.provider_key or "")
- return deployment_mapper.shape_provider_account_response(provider_account)
-
-
-def normalize_flow_version_query_ids(flow_version_ids: list[str] | None) -> list[UUID]:
- if not flow_version_ids:
- return []
- normalized: list[UUID] = []
- seen: set[UUID] = set()
- for raw in flow_version_ids:
- flow_version_uuid = as_uuid(raw.strip())
- if flow_version_uuid is None:
- msg = f"Invalid UUID in flow_version_ids query parameter: '{raw}'"
- raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
- if flow_version_uuid in seen:
- continue
- seen.add(flow_version_uuid)
- normalized.append(flow_version_uuid)
- return normalized
-
-
-def normalize_flow_ids_query(flow_ids: list[UUID] | None) -> list[UUID]:
- """Return a deduplicated list from an already-validated ``flow_ids`` query param.
-
- ``FlowIdsQuery`` (Pydantic) handles UUID parsing and max-length
- validation, so this is intentionally thin.
- """
- if not flow_ids:
- return []
- return list(dict.fromkeys(flow_ids))
-
-
async def flow_version_ids_for_flows(db, *, flow_ids: list[UUID], user_id: UUID) -> list[UUID]:
"""Return all flow-version IDs belonging to the given flows and user."""
if not flow_ids:
diff --git a/src/backend/base/langflow/api/v1/mappers/deployments/watsonx_orchestrate/mapper.py b/src/backend/base/langflow/api/v1/mappers/deployments/watsonx_orchestrate/mapper.py
index 64936f8fb5..1177901eb9 100644
--- a/src/backend/base/langflow/api/v1/mappers/deployments/watsonx_orchestrate/mapper.py
+++ b/src/backend/base/langflow/api/v1/mappers/deployments/watsonx_orchestrate/mapper.py
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
from uuid import UUID
from fastapi import HTTPException, status
+from lfx.log.logger import logger
from lfx.services.adapters.deployment.schema import (
BaseDeploymentData,
BaseDeploymentDataUpdate,
@@ -71,6 +72,9 @@ from langflow.api.v1.mappers.deployments.watsonx_orchestrate.payloads import (
WatsonxApiDeploymentUpdatePayload,
WatsonxApiDeploymentUpdateResultData,
WatsonxApiFlowArtifactProviderData,
+ WatsonxApiProviderAccountCreate,
+ WatsonxApiProviderAccountResponse,
+ WatsonxApiProviderAccountUpdate,
WatsonxApiProviderDeploymentListItem,
WatsonxApiSnapshotListProviderData,
WatsonxApiUpsertFlowItem,
@@ -99,7 +103,11 @@ from langflow.services.adapters.deployment.watsonx_orchestrate.payloads import (
PAYLOAD_SCHEMAS as WXO_ADAPTER_PAYLOAD_SCHEMAS,
)
from langflow.services.adapters.deployment.watsonx_orchestrate.utils import normalize_wxo_name
-from langflow.services.database.models.deployment_provider_account.utils import extract_tenant_from_url
+from langflow.services.database.models.deployment_provider_account.model import DeploymentProviderAccount
+from langflow.services.database.models.deployment_provider_account.utils import (
+ check_provider_url_allowed,
+ extract_tenant_from_url,
+)
from langflow.services.database.models.flow_version_deployment_attachment.crud import (
list_deployment_attachments_for_flow_version_ids,
)
@@ -152,6 +160,8 @@ def _validate_tool_name(name: str) -> str:
class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
"""Deployment mapper for Watsonx Orchestrate provider."""
+ _PROVIDER_LABEL = "watsonx Orchestrate" # used when surfacing errors
+
api_payloads = DeploymentApiPayloads(
deployment_create=PayloadSlot(
adapter_model=WatsonxApiDeploymentCreatePayload,
@@ -197,18 +207,45 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
adapter_model=WatsonxApiDeploymentLlmListResultData,
policy=PayloadSlotPolicy.VALIDATE_ONLY,
),
+ provider_account_create=PayloadSlot(adapter_model=WatsonxApiProviderAccountCreate),
+ provider_account_update=PayloadSlot(adapter_model=WatsonxApiProviderAccountUpdate),
+ provider_account_response=PayloadSlot(
+ adapter_model=WatsonxApiProviderAccountResponse,
+ policy=PayloadSlotPolicy.VALIDATE_ONLY,
+ ),
)
- def resolve_provider_tenant_id(
+ def _validate_create_provider_data(
+ self,
+ provider_data: dict[str, Any],
+ ) -> tuple[WatsonxApiProviderAccountCreate, str]:
+ """Parse, validate, and resolve tenant for create provider_data.
+
+ Returns the parsed payload and the resolved tenant_id.
+ Used by ``resolve_provider_account_create`` which needs the
+ tenant_id for the DB model.
+ """
+ parsed = self._parse_and_check_url(provider_data)
+ tenant_id = parsed.tenant_id
+ if not tenant_id:
+ tenant_id = extract_tenant_from_url(
+ parsed.url,
+ WATSONX_ORCHESTRATE_DEPLOYMENT_ADAPTER_KEY,
+ )
+ if not tenant_id:
+ msg = (
+ "provider_data.tenant_id is required for watsonx-orchestrate provider accounts. "
+ "Provide tenant_id explicitly or use a provider_data.url containing /instances/{tenant_id}."
+ )
+ raise ValueError(msg)
+ return parsed, tenant_id
+
+ def validate_create_provider_url(
self,
*,
- provider_url: str,
provider_data: dict[str, Any],
- ) -> str | None:
- tenant_id = self.resolve_provider_tenant_id_from_data(provider_data=provider_data)
- if tenant_id:
- return tenant_id
- return extract_tenant_from_url(provider_url, WATSONX_ORCHESTRATE_DEPLOYMENT_ADAPTER_KEY)
+ ) -> str:
+ return self._parse_and_check_url(provider_data).url
def format_conflict_detail(self, raw_message: str) -> str:
lower = raw_message.lower()
@@ -226,41 +263,80 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
return "A tool with this name already exists in the provider. Please choose a different name."
return super().format_conflict_detail(raw_message)
- def _validate_provider_data(self, provider_data: dict[str, Any]) -> dict[str, Any]:
- verify_slot = WXO_ADAPTER_PAYLOAD_SCHEMAS.verify_credentials
- credential_payload = self._credential_provider_data(provider_data)
- if verify_slot:
- validated = verify_slot.apply(credential_payload)
- return validated if isinstance(validated, dict) else dict(validated)
- return credential_payload
+ def _parse_and_check_url(
+ self,
+ provider_data: dict[str, Any],
+ ) -> WatsonxApiProviderAccountCreate:
+ """Parse and validate provider_data for the create path.
- def _credential_provider_data(self, provider_data: dict[str, Any]) -> dict[str, Any]:
- """Return provider_data minus mapper-owned metadata keys."""
- credential_payload: dict[str, Any] = dict(provider_data)
- credential_payload.pop("tenant_id", None)
- return credential_payload
+ Validates schema, then checks the URL against the hostname allowlist.
+ """
+ parsed: WatsonxApiProviderAccountCreate = self._parse_api_payload_slot(
+ slot=self.api_payloads.provider_account_create,
+ slot_name="provider_account_create",
+ raw=provider_data,
+ )
+ check_provider_url_allowed(parsed.url, WATSONX_ORCHESTRATE_DEPLOYMENT_ADAPTER_KEY)
+ return parsed
- def resolve_credential_fields(
+ def resolve_credentials(
self,
*,
provider_data: dict[str, Any],
) -> dict[str, Any]:
- validated = self._validate_provider_data(provider_data)
- api_key = validated.get("api_key")
- if not api_key or not isinstance(api_key, str) or not api_key.strip():
- msg = "provider_data must contain a non-empty 'api_key' string"
- raise ValueError(msg)
- return {"api_key": api_key.strip()}
+ parsed: WatsonxApiProviderAccountUpdate = self._parse_api_payload_slot(
+ slot=self.api_payloads.provider_account_update,
+ slot_name="provider_account_update",
+ raw=provider_data,
+ )
+ return parsed.model_dump()
- def resolve_verify_credentials(
+ def resolve_provider_account_create(
+ self,
+ *,
+ payload: DeploymentProviderAccountCreateRequest,
+ user_id: UUID,
+ ) -> DeploymentProviderAccount:
+ """Assemble provider-account DB model for create.
+
+ The returned model carries a **plaintext** ``api_key``. The CRUD
+ layer (``create_provider_account_from_model``) encrypts it before
+ persistence.
+ """
+ parsed, tenant_id = self._validate_create_provider_data(payload.provider_data)
+ return DeploymentProviderAccount(
+ user_id=user_id,
+ name=payload.name,
+ provider_tenant_id=tenant_id,
+ provider_key=payload.provider_key,
+ provider_url=parsed.url,
+ api_key=parsed.api_key,
+ )
+
+ def resolve_provider_account_provider_data(
+ self,
+ provider_account: DeploymentProviderAccount,
+ ) -> dict[str, Any] | None:
+ parsed = self._parse_required_payload_slot(
+ slot=self.api_payloads.provider_account_response,
+ slot_name="provider_account_response",
+ raw={
+ "url": provider_account.provider_url,
+ "tenant_id": provider_account.provider_tenant_id,
+ },
+ operation="building the provider account response",
+ )
+ return parsed.model_dump(mode="json", exclude_none=True)
+
+ def resolve_verify_credentials_for_create(
self,
*,
payload: DeploymentProviderAccountCreateRequest,
) -> VerifyCredentials:
- validated = self._validate_provider_data(payload.provider_data)
+ parsed = self._parse_and_check_url(payload.provider_data)
return VerifyCredentials(
- base_url=payload.url,
- provider_data=validated,
+ base_url=parsed.url,
+ provider_data={"api_key": parsed.api_key},
)
def resolve_verify_credentials_for_update(
@@ -269,18 +345,21 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
payload: DeploymentProviderAccountUpdateRequest,
existing_account: DeploymentProviderAccount,
) -> VerifyCredentials | None:
- provider_data_changed = "provider_data" in payload.model_fields_set
- if not provider_data_changed:
+ if "provider_data" not in payload.model_fields_set:
return None
-
if payload.provider_data is None:
msg = "'provider_data' cannot be null when provided."
raise ValueError(msg)
- provider_data = self.resolve_credential_fields(provider_data=payload.provider_data)
+
+ parsed: WatsonxApiProviderAccountUpdate = self._parse_api_payload_slot(
+ slot=self.api_payloads.provider_account_update,
+ slot_name="provider_account_update",
+ raw=payload.provider_data,
+ )
return VerifyCredentials(
base_url=existing_account.provider_url,
- provider_data=provider_data,
+ provider_data={"api_key": parsed.api_key},
)
def util_create_flow_artifact_provider_data(
@@ -340,8 +419,7 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
slot=WXO_ADAPTER_PAYLOAD_SCHEMAS.deployment_update_result,
slot_name="deployment_update_result",
raw=result.provider_result,
- missing_payload_detail="Deployment provider update result is missing provider_result payload.",
- malformed_payload_detail="Deployment provider update result contains invalid provider_result payload.",
+ operation="updating the deployment",
)
create_slot = WXO_ADAPTER_PAYLOAD_SCHEMAS.deployment_create_result
if create_slot is None:
@@ -701,8 +779,7 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
slot=WXO_ADAPTER_PAYLOAD_SCHEMAS.deployment_create_result,
slot_name="deployment_create_result",
raw=result.provider_result,
- missing_payload_detail="Deployment provider create result is missing provider_result payload.",
- malformed_payload_detail="Deployment provider create result contains invalid provider_result payload.",
+ operation="creating the deployment",
)
created_tools: list[WatsonxApiCreatedTool] = []
for binding in adapter_provider_result.tools_with_refs:
@@ -757,8 +834,7 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
slot=WXO_ADAPTER_PAYLOAD_SCHEMAS.deployment_update_result,
slot_name="deployment_update_result",
raw=result.provider_result,
- missing_payload_detail="Deployment provider update result is missing provider_result payload.",
- malformed_payload_detail="Deployment provider update result contains invalid provider_result payload.",
+ operation="updating the deployment",
)
created_tools = self._to_api_created_tools(
adapter_created_snapshot_bindings=adapter_provider_result.created_snapshot_bindings
@@ -785,8 +861,7 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
slot=WXO_ADAPTER_PAYLOAD_SCHEMAS.deployment_llm_list_result,
slot_name="deployment_llm_list_result",
raw=result.provider_result,
- missing_payload_detail="Deployment provider llm list result is missing provider_result payload.",
- malformed_payload_detail="Deployment provider llm list result contains invalid provider_result payload.",
+ operation="listing available models",
)
api_slot = self.api_payloads.deployment_llm_list_result
if api_slot is None:
@@ -819,8 +894,7 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
slot=slot,
slot_name="deployment_create_result",
raw=result.provider_result,
- missing_payload_detail="Deployment provider create result is missing provider_result payload.",
- malformed_payload_detail="Deployment provider create result contains invalid provider_result payload.",
+ operation="creating the deployment",
)
return CreateSnapshotBindings(
snapshot_bindings=[
@@ -842,8 +916,7 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
slot=slot,
slot_name="deployment_update_result",
raw=result.provider_result,
- missing_payload_detail="Deployment provider update result is missing provider_result payload.",
- malformed_payload_detail="Deployment provider update result contains invalid provider_result payload.",
+ operation="updating the deployment",
)
if not parsed.created_snapshot_ids and not parsed.added_snapshot_bindings:
msg = "Deployment provider update result is missing required snapshot reconciliation bindings."
@@ -868,8 +941,7 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
slot=slot,
slot_name="deployment_update_result",
raw=result.provider_result,
- missing_payload_detail="Deployment provider update result is missing provider_result payload.",
- malformed_payload_detail="Deployment provider update result contains invalid provider_result payload.",
+ operation="updating the deployment",
)
if not parsed.created_snapshot_ids and not parsed.added_snapshot_bindings:
msg = "Deployment provider update result is missing required snapshot reconciliation bindings."
@@ -920,8 +992,7 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
slot=WXO_ADAPTER_PAYLOAD_SCHEMAS.execution_create_result,
slot_name="execution_create_result",
raw=result.provider_result,
- missing_payload_detail="Deployment provider execution result is missing provider_result payload.",
- malformed_payload_detail="Deployment provider execution result contains invalid provider_result payload.",
+ operation="starting the execution",
)
api_provider_result = WatsonxApiAgentExecutionCreateResultData(
execution_id=adapter_provider_result.execution_id,
@@ -935,10 +1006,11 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
cancelled_at=adapter_provider_result.cancelled_at,
last_error=adapter_provider_result.last_error,
)
- provider_result = api_provider_result.model_dump(exclude_none=True) or None
return ExecutionCreateResponse(
deployment_id=deployment_id,
- provider_data=provider_result,
+ provider_data=api_provider_result.model_dump(),
+ # includes None intentionally, simply passes through
+ # wxo api response, which can contain null values
)
def shape_execution_status_result(
@@ -951,8 +1023,7 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
slot=WXO_ADAPTER_PAYLOAD_SCHEMAS.execution_status_result,
slot_name="execution_status_result",
raw=result.provider_result,
- missing_payload_detail="Deployment provider execution result is missing provider_result payload.",
- malformed_payload_detail="Deployment provider execution result contains invalid provider_result payload.",
+ operation="checking execution status",
)
api_provider_result = WatsonxApiAgentExecutionStatusResultData(
execution_id=adapter_provider_result.execution_id,
@@ -966,10 +1037,11 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
cancelled_at=adapter_provider_result.cancelled_at,
last_error=adapter_provider_result.last_error,
)
- provider_result = api_provider_result.model_dump(exclude_none=True) or None
return ExecutionStatusResponse(
deployment_id=deployment_id,
- provider_data=provider_result,
+ provider_data=api_provider_result.model_dump(),
+ # includes None intentionally, simply passes through
+ # wxo api response, which can contain null values
)
def shape_deployment_list_result(
@@ -1097,10 +1169,7 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
total: int,
) -> DeploymentFlowVersionListResponse:
normalized_rows = self._normalize_flow_version_attachment_rows(rows)
- snapshot_data_by_id = self._resolve_snapshot_data_by_id(
- snapshot_result=snapshot_result,
- )
- snapshot_name_by_id = self._resolve_snapshot_name_by_id(
+ flow_version_item_data_by_snapshot_id = self._resolve_flow_version_item_data_by_snapshot_id(
snapshot_result=snapshot_result,
)
@@ -1112,10 +1181,7 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
version_number=row.flow_version.version_number,
attached_at=row.attachment.created_at,
provider_snapshot_id=row.snapshot_id,
- provider_data=self.shape_deployment_flow_version_item_data(
- snapshot_data=snapshot_data_by_id.get(row.snapshot_id),
- tool_name=snapshot_name_by_id.get(row.snapshot_id),
- ),
+ provider_data=flow_version_item_data_by_snapshot_id.get(row.snapshot_id),
)
for row in normalized_rows
]
@@ -1147,81 +1213,51 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
)
return normalized_rows
- def _resolve_snapshot_data_by_id(
+ def _resolve_flow_version_item_data_by_snapshot_id(
self,
*,
snapshot_result: SnapshotListResult | None,
- ) -> dict[str, dict[str, Any] | None]:
+ ) -> dict[str, dict[str, Any]]:
+ """Build API flow-version item provider_data keyed by snapshot id."""
if snapshot_result is None:
return {}
if not snapshot_result.snapshots:
return {}
- snapshot_data_by_id: dict[str, dict[str, Any] | None] = {}
+ item_data_by_snapshot_id: dict[str, dict[str, Any]] = {}
for snapshot in snapshot_result.snapshots:
snapshot_id = str(snapshot.id).strip()
if not snapshot_id:
- continue
+ msg = "Invalid flow-version provider_data payload: snapshot id must be a non-empty string."
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=msg)
+
provider_data = snapshot.provider_data
- snapshot_data_by_id[snapshot_id] = provider_data if isinstance(provider_data, dict) else None
- return snapshot_data_by_id
+ if not isinstance(provider_data, dict) or not provider_data:
+ msg = "Invalid flow-version provider_data payload: snapshot provider_data must be a non-empty object."
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=msg)
- @staticmethod
- def _resolve_snapshot_name_by_id(
- *,
- snapshot_result: SnapshotListResult | None,
- ) -> dict[str, str]:
- """Map snapshot IDs to their provider tool names.
+ raw_connections = provider_data.get("connections")
+ if not isinstance(raw_connections, dict):
+ msg = "Invalid flow-version provider_data payload: connections must be a dict."
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=msg)
- The tool name is the wxO-side name, which may differ from the
- Langflow flow name if the user provided a custom ``tool_name``
- at deploy time or renamed the tool directly in the wxO console.
+ try:
+ item_data_by_snapshot_id[snapshot_id] = self._validate_slot(
+ self.api_payloads.deployment_item_data,
+ {
+ "app_ids": list(raw_connections.keys()),
+ "tool_name": snapshot.name,
+ },
+ )
+ except AdapterPayloadValidationError as exc:
+ detail = exc.format_first_error()
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Invalid flow-version provider_data payload: {detail}",
+ ) from exc
- Edge cases:
- - Provider unreachable / snapshot_result is None: returns ``{}``.
- ``provider_data.tool_name`` will be absent/``None`` and the frontend
- falls back to the Langflow flow name for display.
- - Tool renamed in wxO console: the new name is returned here since
- ``snapshot_result`` is fetched fresh on each request.
- - Tool deleted in wxO: missing from ``snapshot_result.snapshots``,
- so no entry in the returned dict. ``provider_data.tool_name`` will be
- absent/``None``.
- """
- if not snapshot_result or not snapshot_result.snapshots:
- return {}
- result: dict[str, str] = {}
- for snapshot in snapshot_result.snapshots:
- snapshot_id = str(snapshot.id).strip()
- name = str(snapshot.name or "").strip()
- if snapshot_id and name:
- result[snapshot_id] = name
- return result
-
- def shape_deployment_flow_version_item_data(
- self,
- *,
- snapshot_data: dict[str, Any] | None,
- tool_name: str | None = None,
- ) -> dict[str, Any] | None:
- raw_connections = snapshot_data.get("connections") if snapshot_data else None
- app_ids = list(raw_connections.keys()) if isinstance(raw_connections, dict) else []
- if not app_ids and not tool_name:
- return None
- try:
- return self._validate_slot(
- self.api_payloads.deployment_item_data,
- {
- "app_ids": app_ids,
- "tool_name": tool_name,
- },
- )
- except AdapterPayloadValidationError as exc:
- detail = exc.format_first_error()
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Invalid flow-version provider_data payload: {detail}",
- ) from exc
+ return item_data_by_snapshot_id
def _shape_provider_deployment_list_entry(self, item: Any) -> dict[str, Any]:
item_provider_data = item.provider_data
@@ -1256,8 +1292,7 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
slot=self.api_payloads.config_item_data,
slot_name="config_item_data",
raw=provider_data,
- missing_payload_detail="Config item provider_data payload is missing.",
- malformed_payload_detail="Invalid config item provider_data payload:",
+ operation="reading the configuration",
)
def _parse_required_payload_slot(
@@ -1266,24 +1301,33 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
slot: PayloadSlot | None,
slot_name: str,
raw: Any,
- missing_payload_detail: str,
- malformed_payload_detail: str,
+ operation: str = "this operation",
) -> Any:
+ """Parse an adapter result payload, raising 500 on failure.
+
+ Use for data returned **from** the adapter/provider (outbound).
+ Failures are internal errors — the user cannot fix them.
+ ``slot_name`` is logged for debugging but not exposed to the user.
+ See ``_parse_api_payload_slot`` for user-supplied input.
+ """
if slot is None:
- msg = f"Watsonx {slot_name} payload slot is not configured."
+ logger.error("Payload slot '%s' is not configured for %s", slot_name, self._PROVIDER_LABEL)
+ msg = f"The {self._PROVIDER_LABEL} integration is not configured for {operation}."
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=msg)
try:
return slot.parse(raw)
except AdapterPayloadMissingError as exc:
+ logger.error("Empty adapter result for slot '%s' (%s)", slot_name, self._PROVIDER_LABEL)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=missing_payload_detail,
+ detail=f"Empty result while {operation} ({self._PROVIDER_LABEL}).",
) from exc
except AdapterPayloadValidationError as exc:
detail = exc.format_first_error()
+ logger.error("Invalid adapter result for slot '%s' (%s): %s", slot_name, self._PROVIDER_LABEL, detail)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"{malformed_payload_detail} {detail}",
+ detail=f"Unexpected result while {operation} ({self._PROVIDER_LABEL}): {detail}",
) from exc
def _parse_api_payload_slot(
@@ -1293,21 +1337,29 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper):
slot_name: str,
raw: Any,
) -> Any:
+ """Parse a user-supplied API payload, raising 422 on failure.
+
+ Use for data sent **by** the user in the API request (inbound).
+ Failures are input errors — the user can fix them.
+ ``slot_name`` is logged for debugging but not exposed to the user.
+ See ``_parse_required_payload_slot`` for adapter results.
+ """
if slot is None:
- msg = f"Watsonx {slot_name} payload slot is not configured."
+ logger.error("Payload slot '%s' is not configured for %s", slot_name, self._PROVIDER_LABEL)
+ msg = f"The {self._PROVIDER_LABEL} integration is not configured for this operation."
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=msg)
try:
return slot.parse(raw)
except AdapterPayloadMissingError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
- detail="Missing provider_data payload.",
+ detail=f"Missing provider_data for {self._PROVIDER_LABEL}.",
) from exc
except AdapterPayloadValidationError as exc:
detail = exc.format_first_error()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
- detail=f"Invalid provider_data payload: {detail}",
+ detail=f"Invalid provider_data for {self._PROVIDER_LABEL}: {detail}",
) from exc
def _to_bind_provider_operation(self, *, raw_name: str, app_ids: list[str]) -> AdapterPayload:
diff --git a/src/backend/base/langflow/api/v1/mappers/deployments/watsonx_orchestrate/payloads.py b/src/backend/base/langflow/api/v1/mappers/deployments/watsonx_orchestrate/payloads.py
index e839b9ed94..de3d11c280 100644
--- a/src/backend/base/langflow/api/v1/mappers/deployments/watsonx_orchestrate/payloads.py
+++ b/src/backend/base/langflow/api/v1/mappers/deployments/watsonx_orchestrate/payloads.py
@@ -17,6 +17,8 @@ from pydantic import (
)
from langflow.api.v1.mappers.deployments.contracts import CreateFlowArtifactProviderData
+from langflow.api.v1.schemas.deployments import ValidatedUrl
+from langflow.services.database.models.deployment_provider_account.utils import validate_provider_url
WatsonxApiLlmName = Annotated[
str,
@@ -37,6 +39,49 @@ NormalizedStr = Annotated[
]
+class WatsonxApiProviderAccountCreate(BaseModel):
+ """WXO provider-account provider_data contract at API boundary.
+
+ This schema is owned by the WXO mapper and parsed once to validate the
+ provider-account provider_data payload before URL policy checks, credential
+ verification payload shaping, and DB field extraction.
+ """
+
+ model_config = {"extra": "forbid"}
+
+ url: ValidatedUrl
+ tenant_id: Annotated[str | None, StringConstraints(strip_whitespace=True, min_length=1)] = None
+ api_key: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
+
+
+class WatsonxApiProviderAccountUpdate(BaseModel):
+ """WXO mutable provider-account fields for update requests.
+
+ Only credential rotation is supported after create. URL and tenant are
+ immutable and therefore intentionally absent from this schema.
+ """
+
+ model_config = {"extra": "forbid"}
+
+ api_key: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
+
+
+class WatsonxApiProviderAccountResponse(BaseModel):
+ """WXO provider-account provider_data contract for API responses."""
+
+ model_config = {"extra": "forbid"}
+
+ url: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
+ tenant_id: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
+
+ @field_validator("url")
+ @classmethod
+ def validate_url_without_rewriting(cls, value: str) -> str:
+ # Validate URL policy but preserve stored representation.
+ validate_provider_url(value, field_name="url")
+ return value
+
+
class WatsonxApiFlowArtifactProviderData(CreateFlowArtifactProviderData):
"""Watsonx create-time flow artifact provider_data contract."""
@@ -509,25 +554,17 @@ class WatsonxApiSnapshotListProviderData(BaseModel):
class WatsonxApiDeploymentFlowVersionItemData(BaseModel):
- """API-facing provider_data contract for deployment flow-version list items."""
+ """API-facing provider_data contract for deployment flow-version list items.
+
+ ``tool_name`` is required (non-empty) because wxO snapshots always carry a
+ name. Missing or blank names indicate corrupt provider data and the mapper
+ intentionally rejects them with a 500 so the issue surfaces immediately.
+ """
model_config = {"extra": "forbid"}
- app_ids: list[str] = Field(default_factory=list)
- tool_name: str | None = None
-
- @field_validator("app_ids", mode="before")
- @classmethod
- def normalize_app_ids(cls, value: Any) -> list[str]:
- if value is None:
- return []
- return [str(app_id).strip() for app_id in value if str(app_id).strip()]
-
- @field_validator("tool_name", mode="before")
- @classmethod
- def normalize_optional_tool_name(cls, value: Any) -> str | None:
- normalized = str(value or "").strip()
- return normalized or None
+ app_ids: list[NormalizedStr] = Field(default_factory=list)
+ tool_name: NormalizedStr
class WatsonxApiRenameToolOperation(BaseModel):
diff --git a/src/backend/base/langflow/api/v1/schemas/deployments.py b/src/backend/base/langflow/api/v1/schemas/deployments.py
index ebd2e74a13..c3fb4f830e 100644
--- a/src/backend/base/langflow/api/v1/schemas/deployments.py
+++ b/src/backend/base/langflow/api/v1/schemas/deployments.py
@@ -9,10 +9,11 @@ Two identifier domains coexist in these schemas:
``provider_id`` maps to ``deployment_provider_account.id``.
* **Provider-owned (str)** -- ``reference_id``, ``config_id``,
- ``execution_id``, and provider-account fields ``provider_key`` and ``url``.
+ ``execution_id``.
Opaque values assigned or consumed by the external deployment provider.
- Provider-specific metadata (for example tenant/account identifiers) belongs
- inside ``provider_data``.
+ ``provider_key`` is Langflow-owned adapter vocabulary.
+ Provider-specific metadata (for example URL and tenant/account identifiers)
+ belongs inside ``provider_data``.
* **Provider-originated but Langflow-owned once persisted** -- ``resource_key``.
Langflow stores and indexes this as part of its own deployment record.
@@ -39,7 +40,6 @@ from langflow.services.database.models.deployment_provider_account.schemas impor
DeploymentProviderKey,
)
from langflow.services.database.models.deployment_provider_account.utils import (
- check_provider_url_allowed,
validate_provider_url,
)
@@ -48,21 +48,6 @@ from langflow.services.database.models.deployment_provider_account.utils import
# ---------------------------------------------------------------------------
-def _validate_str_id_list(values: list[str], *, field_name: str) -> list[str]:
- """Strip, reject empty/whitespace values, reject empty lists, and deduplicate preserving order."""
- if not values:
- msg = f"{field_name} must not be empty."
- raise ValueError(msg)
- stripped = []
- for raw in values:
- value = raw.strip()
- if not value:
- msg = f"{field_name} must not contain empty values."
- raise ValueError(msg)
- stripped.append(value)
- return list(dict.fromkeys(stripped))
-
-
def _validate_uuid_list(values: list[UUID], *, field_name: str) -> list[UUID]:
"""Deduplicate (preserving order) and reject empty lists."""
deduped = list(dict.fromkeys(values))
@@ -101,15 +86,18 @@ ValidatedUrl = Annotated[str, AfterValidator(validate_provider_url)]
"""URL type that enforces HTTPS and normalizes."""
-def _validate_flow_version_ids(values: list[str] | None) -> list[str] | None:
+def _validate_flow_version_ids(values: list[UUID] | None) -> list[UUID] | None:
"""AfterValidator for optional flow_version_ids query parameter."""
if values is None:
return None
- return _validate_str_id_list(values, field_name="flow_version_ids")
+ return _validate_uuid_list(values, field_name="flow_version_ids")
-FlowVersionIdsQuery = Annotated[list[str] | None, AfterValidator(_validate_flow_version_ids)]
-"""Query parameter type that validates and cleans an optional list of flow version id strings."""
+FlowVersionIdsQuery = Annotated[list[UUID] | None, AfterValidator(_validate_flow_version_ids)]
+"""Optional flow-version filter query parameter.
+
+``None`` means no filter. Empty lists are rejected by validation.
+"""
def _validate_flow_ids(values: list[UUID] | None) -> list[UUID] | None:
@@ -128,7 +116,11 @@ def _validate_flow_ids(values: list[UUID] | None) -> list[UUID] | None:
FlowIdsQuery = Annotated[list[UUID] | None, AfterValidator(_validate_flow_ids)]
-"""Query parameter type that validates and cleans an optional list of flow id UUIDs (max 1 today)."""
+"""Optional flow-id filter query parameter.
+
+``None`` means no filter. Empty lists are rejected by validation.
+Max supported length is 1 today.
+"""
# ---------------------------------------------------------------------------
# Provider sub-resource schemas
@@ -144,24 +136,16 @@ class DeploymentProviderAccountCreateRequest(BaseModel):
),
)
provider_key: DeploymentProviderKey = Field(description="Deployment provider key.")
- url: ValidatedUrl = Field(
- description="Provider service URL persisted in Langflow DB for provider-account resolution.",
- )
provider_data: dict[str, Any] = Field(
min_length=1,
description=(
"Provider-specific credential/metadata payload. "
"Contents are opaque to the API schema; the deployment mapper "
"for the target provider_key validates and extracts credentials "
- "and provider metadata (for example tenant/account identifiers)."
+ "and provider metadata (for example URL/region and tenant/account identifiers)."
),
)
- @model_validator(mode="after")
- def validate_provider_url_allowed(self) -> DeploymentProviderAccountCreateRequest:
- check_provider_url_allowed(self.url, self.provider_key)
- return self
-
class DeploymentProviderAccountUpdateRequest(BaseModel):
model_config = {"extra": "forbid"}
@@ -195,12 +179,11 @@ class DeploymentProviderAccountGetResponse(BaseModel):
id: UUID = Field(description="Langflow DB provider-account UUID (`deployment_provider_account.id`).")
name: str = Field(description="User-chosen display name for this provider account.")
provider_key: DeploymentProviderKey = Field(description="Official provider name used by Langflow.")
- url: str = Field(description="Provider service URL persisted in Langflow DB.")
provider_data: dict[str, Any] | None = Field(
default=None,
description=(
"Provider-owned non-sensitive metadata for this provider account "
- "(for example tenant/account identifiers). Credentials are excluded."
+ "(for example URL, tenant/account identifiers). Credentials are excluded."
),
)
created_at: datetime | None = Field(default=None, description="Langflow DB row creation timestamp.")
diff --git a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/execution.py b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/execution.py
index f347b1a9f7..56ed8679cd 100644
--- a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/execution.py
+++ b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/execution.py
@@ -115,7 +115,6 @@ def create_agent_run_result(payload: dict[str, Any] | None) -> dict[str, Any]:
async def get_agent_run(client: WxOClient, *, run_id: str) -> dict[str, Any]:
payload = await asyncio.to_thread(client.get_run, run_id)
-
if not payload:
msg = f"Watsonx Orchestrate returned an empty response when fetching execution '{run_id}'."
raise DeploymentError(message=msg, error_code="empty_provider_response")
diff --git a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/service.py b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/service.py
index 86179eab0e..b9c6c680de 100644
--- a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/service.py
+++ b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/service.py
@@ -639,9 +639,7 @@ class WatsonxOrchestrateDeploymentService(BaseDeploymentService):
return ExecutionCreateResult(
execution_id=agent_run_result.get("execution_id"),
deployment_id=agent_id,
- provider_result=self.payload_schemas.execution_create_result.parse(agent_run_result).model_dump(
- exclude_none=True
- ),
+ provider_result=self.payload_schemas.execution_create_result.parse(agent_run_result).model_dump(),
)
async def get_execution(
@@ -672,9 +670,7 @@ class WatsonxOrchestrateDeploymentService(BaseDeploymentService):
return ExecutionStatusResult(
execution_id=run_id,
deployment_id=agent_run_result.get("agent_id"),
- provider_result=self.payload_schemas.execution_status_result.parse(agent_run_result).model_dump(
- exclude_none=True
- ),
+ provider_result=self.payload_schemas.execution_status_result.parse(agent_run_result).model_dump(),
)
# TODO: allow listing all configs without filtering by deployment_id
diff --git a/src/backend/base/langflow/services/database/models/deployment_provider_account/crud.py b/src/backend/base/langflow/services/database/models/deployment_provider_account/crud.py
index 4d083ce8b3..b3686b3984 100644
--- a/src/backend/base/langflow/services/database/models/deployment_provider_account/crud.py
+++ b/src/backend/base/langflow/services/database/models/deployment_provider_account/crud.py
@@ -112,6 +112,43 @@ async def create_provider_account(
provider_key: str | DeploymentProviderKey,
provider_url: str,
api_key: str,
+) -> DeploymentProviderAccount:
+ return await _create_provider_account_internal(
+ db=db,
+ user_id=user_id,
+ name=name,
+ provider_tenant_id=provider_tenant_id,
+ provider_key=provider_key,
+ provider_url=provider_url,
+ api_key=api_key,
+ )
+
+
+async def create_provider_account_from_model(
+ db: AsyncSession,
+ *,
+ provider_account: DeploymentProviderAccount,
+) -> DeploymentProviderAccount:
+ return await _create_provider_account_internal(
+ db=db,
+ user_id=provider_account.user_id,
+ name=provider_account.name,
+ provider_tenant_id=provider_account.provider_tenant_id,
+ provider_key=provider_account.provider_key,
+ provider_url=provider_account.provider_url,
+ api_key=provider_account.api_key,
+ )
+
+
+async def _create_provider_account_internal(
+ db: AsyncSession,
+ *,
+ user_id: UUID | str,
+ name: str,
+ provider_tenant_id: str | None,
+ provider_key: str | DeploymentProviderKey,
+ provider_url: str,
+ api_key: str,
) -> DeploymentProviderAccount:
user_uuid = parse_uuid(user_id, field_name="user_id")
diff --git a/src/backend/base/langflow/services/database/models/deployment_provider_account/utils.py b/src/backend/base/langflow/services/database/models/deployment_provider_account/utils.py
index e635c82bda..ef5202cc60 100644
--- a/src/backend/base/langflow/services/database/models/deployment_provider_account/utils.py
+++ b/src/backend/base/langflow/services/database/models/deployment_provider_account/utils.py
@@ -31,20 +31,26 @@ if TYPE_CHECKING:
from collections.abc import Callable
from langflow.services.database.models.deployment_provider_account.schemas import DeploymentProviderKey
-from langflow.services.database.utils import validate_non_empty_string
_ALLOWED_URL_SCHEMES = frozenset({"https"})
_MAX_URL_LENGTH = 2048
-def validate_provider_url(v: str, info: object) -> str:
+def validate_provider_url(v: str, info: object | None = None, *, field_name: str | None = None) -> str:
"""Validate and normalize a provider URL.
Enforces HTTPS-only, rejects embedded credentials, validates the URL
structure, and normalises scheme + host to lowercase.
+
+ *info* is the Pydantic ``ValidationInfo`` passed by field validators.
+ When calling outside a Pydantic context, pass *field_name* directly
+ instead.
"""
- stripped = validate_non_empty_string(v, info)
- field = getattr(info, "field_name", "Field")
+ field = field_name or getattr(info, "field_name", None) or "Field"
+ stripped = v.strip()
+ if not stripped:
+ msg = f"{field} must not be empty"
+ raise ValueError(msg)
if len(stripped) > _MAX_URL_LENGTH:
msg = f"{field} exceeds maximum length of {_MAX_URL_LENGTH}"
@@ -122,8 +128,12 @@ def check_provider_url_allowed(url: str, provider_key: str | DeploymentProviderK
def _extract_wxo_tenant_from_url(url: str) -> str | None:
"""Extract the tenant/instance id from a WXO URL path.
- WXO URLs embed the tenant in the path as ``/instances/{tenant_id}/...``.
- Returns ``None`` if the path does not contain an ``instances`` segment.
+ WXO URLs end with ``/instances/{tenant_id}`` — the tenant must be the
+ **last** path segment. Returns ``None`` if the URL does not match this
+ pattern (no ``instances`` segment, nothing after it, or extra trailing
+ segments).
+
+ See https://www.ibm.com/docs/en/watsonx/watson-orchestrate/base?topic=api-getting-endpoint
"""
parsed = urlparse(url)
path_segments = [segment for segment in parsed.path.split("/") if segment]
@@ -131,10 +141,13 @@ def _extract_wxo_tenant_from_url(url: str) -> str | None:
instances_index = path_segments.index("instances")
except ValueError:
return None
- account_index = instances_index + 1
- if account_index >= len(path_segments):
+ tenant_index = instances_index + 1
+ if tenant_index >= len(path_segments):
return None
- return path_segments[account_index].strip() or None
+ # Tenant must be the terminal segment — reject URLs like /instances/not-an-id/random-path
+ if tenant_index != len(path_segments) - 1:
+ return None
+ return path_segments[tenant_index].strip() or None
_PROVIDER_TENANT_EXTRACTORS: dict[DeploymentProviderKey, Callable[[str], str | None]] = {
diff --git a/src/backend/base/langflow/tests/services/database/models/deployment_provider_account/test_model.py b/src/backend/base/langflow/tests/services/database/models/deployment_provider_account/test_model.py
index a80ae4aa0c..41fdda660d 100644
--- a/src/backend/base/langflow/tests/services/database/models/deployment_provider_account/test_model.py
+++ b/src/backend/base/langflow/tests/services/database/models/deployment_provider_account/test_model.py
@@ -154,7 +154,7 @@ class TestDeploymentProviderAccountTenantConsistency:
DeploymentProviderAccount.model_validate(
{
**self._BASE,
- "provider_url": "https://api.us-south.wxo.cloud.ibm.com/instances/acct-123/agents",
+ "provider_url": "https://api.us-south.wxo.cloud.ibm.com/instances/10000000-0000-0000-0000-000000000123",
"provider_tenant_id": "wrong-tenant",
}
)
@@ -163,17 +163,17 @@ class TestDeploymentProviderAccountTenantConsistency:
account = DeploymentProviderAccount.model_validate(
{
**self._BASE,
- "provider_url": "https://api.us-south.wxo.cloud.ibm.com/instances/acct-123/agents",
- "provider_tenant_id": "acct-123",
+ "provider_url": "https://api.us-south.wxo.cloud.ibm.com/instances/10000000-0000-0000-0000-000000000123",
+ "provider_tenant_id": "10000000-0000-0000-0000-000000000123",
}
)
- assert account.provider_tenant_id == "acct-123"
+ assert account.provider_tenant_id == "10000000-0000-0000-0000-000000000123"
def test_accepts_none_tenant(self):
account = DeploymentProviderAccount.model_validate(
{
**self._BASE,
- "provider_url": "https://api.us-south.wxo.cloud.ibm.com/instances/acct-123/agents",
+ "provider_url": "https://api.us-south.wxo.cloud.ibm.com/instances/10000000-0000-0000-0000-000000000123",
"provider_tenant_id": None,
}
)
diff --git a/src/backend/base/langflow/tests/services/database/models/deployment_provider_account/test_utils.py b/src/backend/base/langflow/tests/services/database/models/deployment_provider_account/test_utils.py
index 12fd08c3d6..4b88e60fb3 100644
--- a/src/backend/base/langflow/tests/services/database/models/deployment_provider_account/test_utils.py
+++ b/src/backend/base/langflow/tests/services/database/models/deployment_provider_account/test_utils.py
@@ -182,13 +182,15 @@ class TestCheckProviderUrlAllowed:
class TestExtractTenantFromUrl:
"""Tests for extract_tenant_from_url dispatch."""
+ _TENANT_UUID = "10000000-0000-0000-0000-000000000123"
+
def test_wxo_extracts_tenant_from_instances_path(self):
- url = "https://api.us-south.wxo.cloud.ibm.com/orchestrate/instances/acct-123/agents"
- assert extract_tenant_from_url(url, DeploymentProviderKey.WATSONX_ORCHESTRATE) == "acct-123"
+ url = f"https://api.us-south.wxo.cloud.ibm.com/orchestrate/instances/{self._TENANT_UUID}"
+ assert extract_tenant_from_url(url, DeploymentProviderKey.WATSONX_ORCHESTRATE) == self._TENANT_UUID
def test_wxo_extracts_tenant_with_string_key(self):
- url = "https://api.us-south.wxo.cloud.ibm.com/orchestrate/instances/acct-123/agents"
- assert extract_tenant_from_url(url, "watsonx-orchestrate") == "acct-123"
+ url = f"https://api.us-south.wxo.cloud.ibm.com/orchestrate/instances/{self._TENANT_UUID}"
+ assert extract_tenant_from_url(url, "watsonx-orchestrate") == self._TENANT_UUID
def test_wxo_returns_none_when_no_instances_segment(self):
url = "https://api.us-south.wxo.cloud.ibm.com/orchestrate/api/v1"
@@ -203,10 +205,13 @@ class TestExtractTenantFromUrl:
assert extract_tenant_from_url(url, DeploymentProviderKey.WATSONX_ORCHESTRATE) is None
def test_wxo_strips_whitespace_from_tenant(self):
- url = "https://api.us-south.wxo.cloud.ibm.com/instances/%20acct-123%20/agents"
+ url = f"https://api.us-south.wxo.cloud.ibm.com/instances/ {self._TENANT_UUID} "
result = extract_tenant_from_url(url, DeploymentProviderKey.WATSONX_ORCHESTRATE)
- assert result is not None
- assert result == result.strip()
+ assert result == self._TENANT_UUID
+
+ def test_wxo_returns_none_when_segments_follow_tenant(self):
+ url = f"https://api.us-south.wxo.cloud.ibm.com/instances/{self._TENANT_UUID}/agents"
+ assert extract_tenant_from_url(url, DeploymentProviderKey.WATSONX_ORCHESTRATE) is None
def test_unknown_provider_raises(self):
with pytest.raises(ValueError, match="is not a valid DeploymentProviderKey"):
@@ -218,12 +223,14 @@ class TestValidateTenantUrlConsistency:
WXO = DeploymentProviderKey.WATSONX_ORCHESTRATE
+ _TENANT_UUID = "10000000-0000-0000-0000-000000000123"
+
def test_passes_when_tenant_matches_url(self):
- url = "https://api.ibm.com/orchestrate/instances/acct-123/agents"
- validate_tenant_url_consistency(url, "acct-123", self.WXO)
+ url = f"https://api.ibm.com/orchestrate/instances/{self._TENANT_UUID}"
+ validate_tenant_url_consistency(url, self._TENANT_UUID, self.WXO)
def test_passes_when_tenant_is_none(self):
- url = "https://api.ibm.com/orchestrate/instances/acct-123/agents"
+ url = f"https://api.ibm.com/orchestrate/instances/{self._TENANT_UUID}"
validate_tenant_url_consistency(url, None, self.WXO)
def test_passes_when_url_has_no_tenant(self):
@@ -235,6 +242,6 @@ class TestValidateTenantUrlConsistency:
validate_tenant_url_consistency(url, None, self.WXO)
def test_raises_when_tenant_contradicts_url(self):
- url = "https://api.ibm.com/orchestrate/instances/acct-123/agents"
+ url = f"https://api.ibm.com/orchestrate/instances/{self._TENANT_UUID}"
with pytest.raises(ValueError, match="does not match"):
- validate_tenant_url_consistency(url, "wrong-tenant", self.WXO)
+ validate_tenant_url_consistency(url, "99000000-0000-0000-0000-000000000999", self.WXO)
diff --git a/src/backend/tests/unit/api/v1/test_deployment_mapper_base.py b/src/backend/tests/unit/api/v1/test_deployment_mapper_base.py
index 935d331e6d..d482745646 100644
--- a/src/backend/tests/unit/api/v1/test_deployment_mapper_base.py
+++ b/src/backend/tests/unit/api/v1/test_deployment_mapper_base.py
@@ -17,7 +17,12 @@ from langflow.api.v1.mappers.deployments.contracts import (
UpdateSnapshotBindings,
)
from langflow.api.v1.mappers.deployments.registry import DeploymentMapperRegistry
-from langflow.api.v1.schemas.deployments import DeploymentCreateRequest, DeploymentUpdateRequest, ExecutionCreateRequest
+from langflow.api.v1.schemas.deployments import (
+ DeploymentCreateRequest,
+ DeploymentProviderAccountCreateRequest,
+ DeploymentUpdateRequest,
+ ExecutionCreateRequest,
+)
from lfx.services.adapters.deployment.payloads import DeploymentPayloadSchemas
from lfx.services.adapters.deployment.schema import (
DeploymentCreateResult,
@@ -118,9 +123,9 @@ OUTBOUND_SLOT_NAMES = [
def test_api_payload_field_names_match_adapter_registry() -> None:
- api_fields = [field.name for field in fields(DeploymentApiPayloads)]
- adapter_fields = [field.name for field in fields(DeploymentPayloadSchemas)]
- assert api_fields == adapter_fields
+ api_fields = {field.name for field in fields(DeploymentApiPayloads)}
+ adapter_fields = {field.name for field in fields(DeploymentPayloadSchemas)}
+ assert adapter_fields.issubset(api_fields), f"Adapter fields not in API payloads: {adapter_fields - api_fields}"
@pytest.mark.asyncio
@@ -691,24 +696,6 @@ def test_base_mapper_exposes_reconciliation_resolvers() -> None:
assert mapper.util_resource_key_from_execution(exec_result) == "dep-1"
-def test_base_mapper_resolve_provider_tenant_id_passthrough() -> None:
- mapper = BaseDeploymentMapper()
- assert (
- mapper.resolve_provider_tenant_id(
- provider_url="https://example.com/instances/abc",
- provider_data={"tenant_id": "tenant-1"},
- )
- == "tenant-1"
- )
- assert (
- mapper.resolve_provider_tenant_id(
- provider_url="https://example.com/instances/abc",
- provider_data={},
- )
- is None
- )
-
-
def test_base_mapper_shapes_provider_account_response() -> None:
mapper = BaseDeploymentMapper()
timestamp = datetime.now(tz=timezone.utc)
@@ -722,12 +709,11 @@ def test_base_mapper_shapes_provider_account_response() -> None:
updated_at=timestamp,
)
- shaped = mapper.shape_provider_account_response(account)
+ shaped = mapper.resolve_provider_account_response(account)
assert shaped.id == account.id
assert shaped.name == "staging"
- assert shaped.provider_data == {"tenant_id": "tenant-1"}
+ assert shaped.provider_data == {"url": "https://provider.example"}
assert shaped.provider_key == "watsonx-orchestrate"
- assert shaped.url == "https://provider.example"
def test_mapper_registry_returns_default_when_unregistered() -> None:
@@ -772,33 +758,83 @@ def test_mapper_registry_get_returns_cached_instance_for_key() -> None:
# ---------------------------------------------------------------------------
-# resolve_verify_credentials
+# resolve_verify_credentials_for_create
# ---------------------------------------------------------------------------
-def test_base_mapper_resolve_verify_credentials_extracts_url() -> None:
- """Base mapper builds VerifyCredentials with url only."""
+def test_base_mapper_resolve_verify_credentials_raises_not_implemented() -> None:
+ """Base mapper does not implement create credential verification."""
from langflow.api.v1.schemas.deployments import DeploymentProviderAccountCreateRequest
- from lfx.services.adapters.deployment.schema import VerifyCredentials
mapper = BaseDeploymentMapper()
payload = DeploymentProviderAccountCreateRequest(
name="test-account",
provider_key="watsonx-orchestrate",
- url="https://api.us-south.wxo.cloud.ibm.com",
- provider_data={"api_key": "secret-key"}, # pragma: allowlist secret
+ provider_data={
+ "url": "https://api.us-south.wxo.cloud.ibm.com",
+ "api_key": "secret-key", # pragma: allowlist secret
+ },
)
- result = mapper.resolve_verify_credentials(payload=payload)
- assert isinstance(result, VerifyCredentials)
- assert "cloud.ibm.com" in result.base_url
- assert result.provider_data is None
+ with pytest.raises(NotImplementedError):
+ mapper.resolve_verify_credentials_for_create(payload=payload)
-def test_base_mapper_resolve_credential_fields_raises_not_implemented() -> None:
- """Base mapper does not implement resolve_credential_fields."""
+def test_base_mapper_resolve_credentials_raises_not_implemented() -> None:
+ """Base mapper does not implement resolve_credentials."""
mapper = BaseDeploymentMapper()
with pytest.raises(NotImplementedError):
- mapper.resolve_credential_fields(provider_data={"api_key": "key"}) # pragma: allowlist secret
+ mapper.resolve_credentials(provider_data={"api_key": "key"}) # pragma: allowlist secret
+
+
+def test_base_mapper_resolve_provider_account_create_raises_not_implemented() -> None:
+ mapper = BaseDeploymentMapper()
+ payload = DeploymentProviderAccountCreateRequest(
+ name="provider-account",
+ provider_key="watsonx-orchestrate",
+ provider_data={
+ "url": "https://api.us-south.wxo.cloud.ibm.com/instances/tenant-1",
+ "tenant_id": "tenant-1",
+ "api_key": "secret-key", # pragma: allowlist secret
+ },
+ )
+ with pytest.raises(NotImplementedError):
+ mapper.resolve_provider_account_create(payload=payload, user_id=uuid4())
+
+
+def test_base_mapper_util_existing_deployment_resource_key_for_create_raises_not_implemented() -> None:
+ mapper = BaseDeploymentMapper()
+ payload = DeploymentCreateRequest(
+ provider_id=uuid4(),
+ name="deploy",
+ description="",
+ type="agent",
+ provider_data={},
+ )
+ with pytest.raises(NotImplementedError):
+ mapper.util_existing_deployment_resource_key_for_create(payload)
+
+
+def test_base_mapper_util_should_mutate_provider_for_existing_deployment_create_raises_not_implemented() -> None:
+ mapper = BaseDeploymentMapper()
+ payload = DeploymentCreateRequest(
+ provider_id=uuid4(),
+ name="deploy",
+ description="",
+ type="agent",
+ provider_data={},
+ )
+ with pytest.raises(NotImplementedError):
+ mapper.util_should_mutate_provider_for_existing_deployment_create(payload)
+
+
+def test_base_mapper_util_create_result_from_existing_update_raises_not_implemented() -> None:
+ mapper = BaseDeploymentMapper()
+ result = DeploymentUpdateResult(id="provider-deploy-id")
+ with pytest.raises(NotImplementedError):
+ mapper.util_create_result_from_existing_update(
+ existing_resource_key="provider-deploy-id",
+ result=result,
+ )
# ---------------------------------------------------------------------------
@@ -809,8 +845,8 @@ def test_base_mapper_resolve_credential_fields_raises_not_implemented() -> None:
def _make_existing_account():
"""Build a minimal fake existing DeploymentProviderAccount."""
return SimpleNamespace(
- provider_url="https://api.us-south.wxo.cloud.ibm.com/instances/old-tenant/agents",
- provider_tenant_id="old-tenant",
+ provider_url="https://api.us-south.wxo.cloud.ibm.com/instances/30000000-0000-0000-0000-000000000001",
+ provider_tenant_id="30000000-0000-0000-0000-000000000001",
provider_key="watsonx-orchestrate",
)
diff --git a/src/backend/tests/unit/api/v1/test_deployment_mapper_watsonx.py b/src/backend/tests/unit/api/v1/test_deployment_mapper_watsonx.py
index b106849ea3..ef2f608e0b 100644
--- a/src/backend/tests/unit/api/v1/test_deployment_mapper_watsonx.py
+++ b/src/backend/tests/unit/api/v1/test_deployment_mapper_watsonx.py
@@ -227,28 +227,56 @@ def test_watsonx_mapper_formats_conflict_detail(raw_message: str, expected: str)
assert detail == expected
-def test_watsonx_mapper_shapes_flow_version_item_data_from_connections() -> None:
+def test_watsonx_mapper_flow_version_item_data_from_snapshot_connections() -> None:
mapper = WatsonxOrchestrateDeploymentMapper()
+ snapshot_result = SnapshotListResult(
+ snapshots=[
+ SnapshotItem(
+ id="tool-1",
+ name="Tool 1",
+ provider_data={"connections": {"cfg-1": "conn-1", "cfg-2": "conn-2"}},
+ )
+ ]
+ )
+ shaped_by_snapshot_id = mapper._resolve_flow_version_item_data_by_snapshot_id(snapshot_result=snapshot_result)
- shaped = mapper.shape_deployment_flow_version_item_data(
- snapshot_data={"connections": {"cfg-1": "conn-1", "cfg-2": "conn-2"}},
- tool_name="Tool 1",
+ assert shaped_by_snapshot_id == {"tool-1": {"app_ids": ["cfg-1", "cfg-2"], "tool_name": "Tool 1"}}
+
+
+def test_wxo_mapper_flow_version_item_data_rejects_empty_tool_name() -> None:
+ mapper = WatsonxOrchestrateDeploymentMapper()
+ snapshot_result = SnapshotListResult(
+ snapshots=[
+ SnapshotItem(
+ id="tool-1",
+ name="",
+ provider_data={"connections": {}},
+ )
+ ]
)
- assert shaped == {"app_ids": ["cfg-1", "cfg-2"], "tool_name": "Tool 1"}
+ with pytest.raises(HTTPException) as exc_info:
+ mapper._resolve_flow_version_item_data_by_snapshot_id(snapshot_result=snapshot_result)
+ assert exc_info.value.status_code == 500
+ assert "Invalid flow-version provider_data payload:" in str(exc_info.value.detail)
-def test_watsonx_mapper_flow_version_item_data_handles_missing_invalid_and_empty_connections() -> None:
+def test_wxo_mapper_flow_version_item_data_rejects_empty_provider_data() -> None:
mapper = WatsonxOrchestrateDeploymentMapper()
+ snapshot_result = SnapshotListResult(
+ snapshots=[
+ SnapshotItem(
+ id="tool-1",
+ name="Tool 1",
+ provider_data={},
+ )
+ ]
+ )
- assert mapper.shape_deployment_flow_version_item_data(snapshot_data=None) is None
- assert mapper.shape_deployment_flow_version_item_data(snapshot_data={}) is None
- assert mapper.shape_deployment_flow_version_item_data(snapshot_data={"connections": []}) is None
- assert mapper.shape_deployment_flow_version_item_data(snapshot_data={"connections": {}}) is None
- assert mapper.shape_deployment_flow_version_item_data(snapshot_data=None, tool_name="Tool 1") == {
- "app_ids": [],
- "tool_name": "Tool 1",
- }
+ with pytest.raises(HTTPException) as exc_info:
+ mapper._resolve_flow_version_item_data_by_snapshot_id(snapshot_result=snapshot_result)
+ assert exc_info.value.status_code == 500
+ assert "snapshot provider_data must be a non-empty object" in str(exc_info.value.detail)
def test_watsonx_mapper_shapes_flow_version_list_result_with_enrichment() -> None:
@@ -461,7 +489,7 @@ def test_watsonx_mapper_config_list_fails_fast_when_type_missing() -> None:
mapper.shape_config_list_result(result, page=1, size=10)
assert exc_info.value.status_code == 500
detail = str(exc_info.value.detail)
- assert "Invalid config item provider_data payload:" in detail
+ assert "Unexpected result while reading the configuration" in detail
assert "'type'" in detail
@@ -508,7 +536,7 @@ def test_watsonx_mapper_config_list_fails_fast_when_environment_missing() -> Non
mapper.shape_config_list_result(result, page=1, size=10)
assert exc_info.value.status_code == 500
detail = str(exc_info.value.detail)
- assert "Invalid config item provider_data payload:" in detail
+ assert "Unexpected result while reading the configuration" in detail
assert "'environment'" in detail
@@ -529,7 +557,7 @@ def test_watsonx_mapper_config_list_rejects_missing_type_even_with_other_provide
mapper.shape_config_list_result(result, page=1, size=10)
assert exc_info.value.status_code == 500
detail = str(exc_info.value.detail)
- assert "Invalid config item provider_data payload:" in detail
+ assert "Unexpected result while reading the configuration" in detail
assert "'type'" in detail
@@ -800,6 +828,60 @@ def test_watsonx_mapper_resolve_verify_credentials_for_update_prefers_new_provid
assert verify_input.provider_data == {"api_key": "new-api-key"} # pragma: allowlist secret
+def test_watsonx_mapper_resolve_verify_credentials_for_update_rejects_url_update() -> None:
+ """WatsonxApiProviderAccountUpdate (extra='forbid') rejects url in provider_data."""
+ from langflow.services.database.models.deployment_provider_account.model import DeploymentProviderAccount
+
+ mapper = WatsonxOrchestrateDeploymentMapper()
+ existing_account = DeploymentProviderAccount(
+ id=uuid4(),
+ user_id=uuid4(),
+ name="prod",
+ provider_tenant_id="tenant-1",
+ provider_key="watsonx-orchestrate",
+ provider_url="https://api.us-south.wxo.cloud.ibm.com/instances/tenant-1",
+ api_key="encrypted-api-key", # pragma: allowlist secret
+ )
+ payload = DeploymentProviderAccountUpdateRequest(
+ provider_data={
+ "url": "https://api.us-south.wxo.cloud.ibm.com/instances/tenant-2",
+ "api_key": "new-api-key", # pragma: allowlist secret
+ }
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ mapper.resolve_verify_credentials_for_update(payload=payload, existing_account=existing_account)
+ assert exc_info.value.status_code == 422
+ assert "url" in str(exc_info.value.detail).lower()
+
+
+def test_watsonx_mapper_resolve_verify_credentials_for_update_rejects_tenant_id_update() -> None:
+ """WatsonxApiProviderAccountUpdate (extra='forbid') rejects tenant_id in provider_data."""
+ from langflow.services.database.models.deployment_provider_account.model import DeploymentProviderAccount
+
+ mapper = WatsonxOrchestrateDeploymentMapper()
+ existing_account = DeploymentProviderAccount(
+ id=uuid4(),
+ user_id=uuid4(),
+ name="prod",
+ provider_tenant_id="tenant-1",
+ provider_key="watsonx-orchestrate",
+ provider_url="https://api.us-south.wxo.cloud.ibm.com/instances/tenant-1",
+ api_key="encrypted-api-key", # pragma: allowlist secret
+ )
+ payload = DeploymentProviderAccountUpdateRequest(
+ provider_data={
+ "tenant_id": "tenant-2",
+ "api_key": "new-api-key", # pragma: allowlist secret
+ }
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ mapper.resolve_verify_credentials_for_update(payload=payload, existing_account=existing_account)
+ assert exc_info.value.status_code == 422
+ assert "tenant_id" in str(exc_info.value.detail).lower()
+
+
@pytest.mark.asyncio
async def test_watsonx_mapper_resolve_update_passthrough_without_provider_data() -> None:
mapper = WatsonxOrchestrateDeploymentMapper()
@@ -1084,7 +1166,7 @@ async def test_watsonx_mapper_create_reports_missing_llm_field_name() -> None:
)
assert exc.value.status_code == 422
- assert exc.value.detail == "Invalid provider_data payload: Missing required field 'llm'."
+ assert exc.value.detail == "Invalid provider_data for watsonx Orchestrate: Missing required field 'llm'."
@pytest.mark.asyncio
@@ -1116,7 +1198,10 @@ async def test_watsonx_mapper_create_reports_unknown_field_name() -> None:
)
assert exc.value.status_code == 422
- assert exc.value.detail == "Invalid provider_data payload: Invalid field 'resource_name_prefix'. Please remove it."
+ assert (
+ exc.value.detail
+ == "Invalid provider_data for watsonx Orchestrate: Invalid field 'resource_name_prefix'. Please remove it."
+ )
@pytest.mark.asyncio
@@ -1495,7 +1580,7 @@ def test_watsonx_mapper_llm_list_result_raises_for_missing_provider_payload() ->
with pytest.raises(HTTPException) as exc:
mapper.shape_llm_list_result(result)
assert exc.value.status_code == 500
- assert "missing provider_result payload" in exc.value.detail
+ assert "Empty result while listing available models" in exc.value.detail
def test_watsonx_mapper_exposes_reconciliation_resolvers() -> None:
@@ -1561,23 +1646,29 @@ def test_watsonx_mapper_exposes_reconciliation_resolvers() -> None:
assert update_bindings.to_source_ref_map() == {str(add_id): "snap-1"}
-def test_watsonx_mapper_resolve_provider_tenant_id_from_url() -> None:
+def test_wxo_mapper_provider_account_response_includes_tenant_id() -> None:
mapper = WatsonxOrchestrateDeploymentMapper()
- assert (
- mapper.resolve_provider_tenant_id(
- provider_url="https://api.example.com/orchestrate/instances/account-123/agents",
- provider_data={},
- )
- == "account-123"
- )
- assert (
- mapper.resolve_provider_tenant_id(
- provider_url="https://api.example.com/orchestrate/instances/account-123/agents",
- provider_data={"tenant_id": "tenant-explicit"},
- )
- == "tenant-explicit"
+ timestamp = datetime.now(tz=timezone.utc)
+ account = SimpleNamespace(
+ id=uuid4(),
+ name="staging",
+ provider_tenant_id="tenant-1",
+ provider_key="watsonx-orchestrate",
+ provider_url="https://provider.example",
+ created_at=timestamp,
+ updated_at=timestamp,
)
+ shaped = mapper.resolve_provider_account_response(account)
+
+ assert shaped.id == account.id
+ assert shaped.name == "staging"
+ assert shaped.provider_key == "watsonx-orchestrate"
+ assert shaped.provider_data == {
+ "url": "https://provider.example",
+ "tenant_id": "tenant-1",
+ }
+
def test_watsonx_mapper_trusts_top_level_deployment_id() -> None:
"""WXO mapper inherits base behavior: trust result.deployment_id directly."""
@@ -1593,11 +1684,11 @@ def test_watsonx_mapper_trusts_top_level_deployment_id() -> None:
# ---------------------------------------------------------------------------
-# resolve_verify_credentials
+# resolve_verify_credentials_for_create
# ---------------------------------------------------------------------------
-def test_wxo_mapper_resolve_verify_credentials_filters_non_credential_fields() -> None:
+def test_wxo_mapper_verify_credentials_create_filters_non_credential_fields() -> None:
"""WXO mapper forwards only credential fields to adapter verification."""
from langflow.api.v1.schemas.deployments import DeploymentProviderAccountCreateRequest
from lfx.services.adapters.deployment.schema import VerifyCredentials
@@ -1606,13 +1697,13 @@ def test_wxo_mapper_resolve_verify_credentials_filters_non_credential_fields() -
payload = DeploymentProviderAccountCreateRequest(
name="test-account",
provider_key="watsonx-orchestrate",
- url="https://api.us-south.wxo.cloud.ibm.com",
provider_data={
+ "url": "https://api.us-south.wxo.cloud.ibm.com",
"tenant_id": "tenant-123",
"api_key": "my-secret-key", # pragma: allowlist secret
},
)
- result = mapper.resolve_verify_credentials(payload=payload)
+ result = mapper.resolve_verify_credentials_for_create(payload=payload)
assert isinstance(result, VerifyCredentials)
assert "cloud.ibm.com" in result.base_url
assert result.provider_data is not None
@@ -1620,65 +1711,147 @@ def test_wxo_mapper_resolve_verify_credentials_filters_non_credential_fields() -
assert "tenant_id" not in result.provider_data
-def test_wxo_mapper_resolve_credential_fields_returns_api_key() -> None:
- """WXO mapper extracts api_key from provider_data for DB storage."""
- mapper = WatsonxOrchestrateDeploymentMapper()
- result = mapper.resolve_credential_fields(provider_data={"api_key": "my-key"}) # pragma: allowlist secret
- assert result == {"api_key": "my-key"} # pragma: allowlist secret
-
-
-def test_wxo_mapper_resolve_credential_fields_ignores_tenant_metadata() -> None:
- """Tenant metadata in provider_data should not break credential extraction."""
- mapper = WatsonxOrchestrateDeploymentMapper()
- result = mapper.resolve_credential_fields(
- provider_data={
- "tenant_id": "tenant-123",
- "api_key": "my-key", # pragma: allowlist secret
- }
- )
- assert result == {"api_key": "my-key"} # pragma: allowlist secret
-
-
-def test_wxo_mapper_resolve_verify_credentials_rejects_unknown_non_metadata_fields() -> None:
- """Mapper strips tenant metadata but still rejects unexpected credential keys."""
+def test_wxo_mapper_verify_credentials_create_accepts_missing_tenant() -> None:
+ """Verify-credentials path only parses; tenant validation is deferred to resolve_provider_account_create."""
from langflow.api.v1.schemas.deployments import DeploymentProviderAccountCreateRequest
- from lfx.services.adapters.payload import AdapterPayloadValidationError
+ from lfx.services.adapters.deployment.schema import VerifyCredentials
mapper = WatsonxOrchestrateDeploymentMapper()
payload = DeploymentProviderAccountCreateRequest(
name="test-account",
provider_key="watsonx-orchestrate",
- url="https://api.us-south.wxo.cloud.ibm.com",
provider_data={
+ "url": "https://api.us-south.wxo.cloud.ibm.com",
+ "api_key": "my-secret-key", # pragma: allowlist secret
+ },
+ )
+
+ result = mapper.resolve_verify_credentials_for_create(payload=payload)
+ assert isinstance(result, VerifyCredentials)
+ assert "cloud.ibm.com" in result.base_url
+
+
+def test_wxo_mapper_provider_account_create_requires_tenant() -> None:
+ """Create path rejects payloads with no explicit or URL-derived tenant."""
+ from langflow.api.v1.schemas.deployments import DeploymentProviderAccountCreateRequest
+
+ mapper = WatsonxOrchestrateDeploymentMapper()
+ payload = DeploymentProviderAccountCreateRequest(
+ name="test-account",
+ provider_key="watsonx-orchestrate",
+ provider_data={
+ "url": "https://api.us-south.wxo.cloud.ibm.com",
+ "api_key": "my-secret-key", # pragma: allowlist secret
+ },
+ )
+
+ with pytest.raises(ValueError, match=r"provider_data\.tenant_id is required"):
+ mapper.resolve_provider_account_create(payload=payload, user_id="user-1")
+
+
+def test_wxo_mapper_resolve_credentials_returns_api_key() -> None:
+ """WXO mapper extracts api_key from provider_data for DB storage."""
+ mapper = WatsonxOrchestrateDeploymentMapper()
+ result = mapper.resolve_credentials(provider_data={"api_key": "my-key"}) # pragma: allowlist secret
+ assert result == {"api_key": "my-key"} # pragma: allowlist secret
+
+
+def test_wxo_mapper_resolve_credentials_rejects_tenant_metadata() -> None:
+ """Update-path credential extraction rejects non-credential fields."""
+ mapper = WatsonxOrchestrateDeploymentMapper()
+ with pytest.raises(HTTPException) as exc_info:
+ mapper.resolve_credentials(
+ provider_data={
+ "tenant_id": "tenant-123",
+ "api_key": "my-key", # pragma: allowlist secret
+ }
+ )
+ assert exc_info.value.status_code == 422
+ assert "tenant_id" in exc_info.value.detail
+
+
+def test_wxo_mapper_verify_credentials_create_rejects_unknown_fields() -> None:
+ """Mapper rejects unexpected provider_data keys."""
+ from langflow.api.v1.schemas.deployments import DeploymentProviderAccountCreateRequest
+
+ mapper = WatsonxOrchestrateDeploymentMapper()
+ payload = DeploymentProviderAccountCreateRequest(
+ name="test-account",
+ provider_key="watsonx-orchestrate",
+ provider_data={
+ "url": "https://api.us-south.wxo.cloud.ibm.com",
"tenant_id": "tenant-123",
"api_key": "my-secret-key", # pragma: allowlist secret
"unexpected": "field",
},
)
- with pytest.raises(AdapterPayloadValidationError):
- mapper.resolve_verify_credentials(payload=payload)
+ with pytest.raises(HTTPException) as exc_info:
+ mapper.resolve_verify_credentials_for_create(payload=payload)
+ assert exc_info.value.status_code == 422
+ assert "Invalid field 'unexpected'" in exc_info.value.detail
-def test_wxo_mapper_resolve_credential_fields_strips_whitespace() -> None:
+def test_wxo_mapper_resolve_credentials_strips_whitespace() -> None:
"""WXO mapper strips whitespace from api_key."""
mapper = WatsonxOrchestrateDeploymentMapper()
- result = mapper.resolve_credential_fields(provider_data={"api_key": " my-key "}) # pragma: allowlist secret
+ result = mapper.resolve_credentials(provider_data={"api_key": " my-key "}) # pragma: allowlist secret
assert result == {"api_key": "my-key"} # pragma: allowlist secret
-def test_wxo_mapper_resolve_credential_fields_rejects_empty() -> None:
+def test_wxo_mapper_resolve_credentials_rejects_empty() -> None:
"""WXO mapper rejects empty api_key in provider_data."""
- from lfx.services.adapters.payload import AdapterPayloadValidationError
+ mapper = WatsonxOrchestrateDeploymentMapper()
+ with pytest.raises(HTTPException) as exc_info:
+ mapper.resolve_credentials(provider_data={"api_key": ""})
+ assert exc_info.value.status_code == 422
+ assert "api_key" in exc_info.value.detail
+
+ with pytest.raises(HTTPException) as exc_info:
+ mapper.resolve_credentials(provider_data={"api_key": " "})
+ assert exc_info.value.status_code == 422
+ assert "api_key" in exc_info.value.detail
+
+ with pytest.raises(HTTPException) as exc_info:
+ mapper.resolve_credentials(provider_data={})
+ assert exc_info.value.status_code == 422
+ assert "api_key" in exc_info.value.detail
+
+
+def test_wxo_mapper_provider_account_create_assembles_model() -> None:
+ from langflow.api.v1.schemas.deployments import DeploymentProviderAccountCreateRequest
mapper = WatsonxOrchestrateDeploymentMapper()
- with pytest.raises(ValueError, match="non-empty"):
- mapper.resolve_credential_fields(provider_data={"api_key": ""})
+ payload = DeploymentProviderAccountCreateRequest(
+ name="test-account",
+ provider_key="watsonx-orchestrate",
+ provider_data={
+ "url": "https://api.us-south.wxo.cloud.ibm.com/instances/tenant-123",
+ "tenant_id": "tenant-123",
+ "api_key": "my-secret-key", # pragma: allowlist secret
+ },
+ )
+ result = mapper.resolve_provider_account_create(payload=payload, user_id=uuid4())
+ assert result.name == "test-account"
+ assert result.provider_url == "https://api.us-south.wxo.cloud.ibm.com/instances/tenant-123"
+ assert result.provider_tenant_id == "tenant-123"
+ assert result.api_key == "my-secret-key" # pragma: allowlist secret
- with pytest.raises(ValueError, match="non-empty"):
- mapper.resolve_credential_fields(provider_data={"api_key": " "})
- with pytest.raises(AdapterPayloadValidationError):
- mapper.resolve_credential_fields(provider_data={})
+def test_wxo_mapper_provider_account_create_uses_url_tenant_fallback() -> None:
+ from langflow.api.v1.schemas.deployments import DeploymentProviderAccountCreateRequest
+
+ mapper = WatsonxOrchestrateDeploymentMapper()
+ payload = DeploymentProviderAccountCreateRequest(
+ name="test-account",
+ provider_key="watsonx-orchestrate",
+ provider_data={
+ "url": "https://api.us-south.wxo.cloud.ibm.com/instances/tenant-123",
+ "api_key": "my-secret-key", # pragma: allowlist secret
+ },
+ )
+
+ result = mapper.resolve_provider_account_create(payload=payload, user_id=uuid4())
+ assert result.provider_tenant_id == "tenant-123"
# ---------------------------------------------------------------------------
@@ -1689,8 +1862,8 @@ def test_wxo_mapper_resolve_credential_fields_rejects_empty() -> None:
def _make_wxo_existing_account():
"""Build a minimal fake existing WXO DeploymentProviderAccount."""
return SimpleNamespace(
- provider_url="https://api.us-south.wxo.cloud.ibm.com/instances/old-tenant/agents",
- provider_tenant_id="old-tenant",
+ provider_url="https://api.us-south.wxo.cloud.ibm.com/instances/30000000-0000-0000-0000-000000000001",
+ provider_tenant_id="30000000-0000-0000-0000-000000000001",
provider_key="watsonx-orchestrate",
)
diff --git a/src/backend/tests/unit/api/v1/test_deployment_route_handlers.py b/src/backend/tests/unit/api/v1/test_deployment_route_handlers.py
index 67fb8594cf..164cc703c6 100644
--- a/src/backend/tests/unit/api/v1/test_deployment_route_handlers.py
+++ b/src/backend/tests/unit/api/v1/test_deployment_route_handlers.py
@@ -882,7 +882,6 @@ class TestListDeploymentFlowVersionsRoute:
class TestProviderAccountRoutes:
@pytest.mark.asyncio
- @patch(f"{ROUTES_MODULE}.to_provider_account_response", return_value={"ok": True})
@patch(f"{ROUTES_MODULE}.update_provider_account_row", new_callable=AsyncMock)
@patch(f"{ROUTES_MODULE}.resolve_deployment_adapter")
@patch(f"{ROUTES_MODULE}.get_deployment_mapper")
@@ -893,7 +892,6 @@ class TestProviderAccountRoutes:
mock_get_mapper,
mock_resolve_adapter,
mock_update_provider_account,
- mock_to_provider_response, # noqa: ARG002
):
"""PATCH skips credential verification when only name changes."""
from langflow.api.v1.deployments import update_provider_account
@@ -971,9 +969,8 @@ class TestProviderAccountRoutes:
from langflow.api.v1.deployments import create_provider_account
mapper = MagicMock()
- mapper.resolve_verify_credentials.return_value = MagicMock()
- mapper.resolve_credential_fields.return_value = {"api_key": "api-key"} # pragma: allowlist secret
- mapper.resolve_provider_tenant_id.return_value = "tenant-1"
+ mapper.resolve_verify_credentials_for_create.return_value = MagicMock()
+ mapper.resolve_provider_account_create.return_value = MagicMock()
mock_get_mapper.return_value = mapper
adapter = AsyncMock()
@@ -983,8 +980,10 @@ class TestProviderAccountRoutes:
payload = DeploymentProviderAccountCreateRequest(
name="prod",
provider_key=DeploymentProviderKey.WATSONX_ORCHESTRATE,
- url="https://api.us-south.wxo.cloud.ibm.com/instances/tenant-1",
- provider_data={"api_key": "api-key"}, # pragma: allowlist secret
+ provider_data={
+ "url": "https://api.us-south.wxo.cloud.ibm.com/instances/tenant-1",
+ "api_key": "api-key", # pragma: allowlist secret
+ },
)
with pytest.raises(HTTPException) as exc_info:
diff --git a/src/backend/tests/unit/api/v1/test_deployment_schemas.py b/src/backend/tests/unit/api/v1/test_deployment_schemas.py
index 95fa8c3003..f468fccdf4 100644
--- a/src/backend/tests/unit/api/v1/test_deployment_schemas.py
+++ b/src/backend/tests/unit/api/v1/test_deployment_schemas.py
@@ -23,6 +23,8 @@ from langflow.api.v1.schemas.deployments import (
from langflow.services.database.models.deployment_provider_account.schemas import DeploymentProviderKey
from pydantic import ValidationError
+TEST_API_KEY = "key" # pragma: allowlist secret
+
# ---------------------------------------------------------------------------
# Security: credentials must never appear in response schemas
# ---------------------------------------------------------------------------
@@ -45,12 +47,11 @@ class TestCredentialSecurity:
id=uuid4(),
name="staging",
provider_key=DeploymentProviderKey.WATSONX_ORCHESTRATE,
- url="https://api.us-south.wxo.cloud.ibm.com",
- provider_data={"tenant_id": "tenant-1"},
+ provider_data={"url": "https://api.us-south.wxo.cloud.ibm.com", "tenant_id": "tenant-1"},
)
dumped = response.model_dump()
assert "api_key" not in dumped
- assert dumped["provider_data"] == {"tenant_id": "tenant-1"}
+ assert dumped["provider_data"] == {"url": "https://api.us-south.wxo.cloud.ibm.com", "tenant_id": "tenant-1"}
assert "api_key" not in (dumped["provider_data"] or {})
@@ -64,8 +65,7 @@ class TestProviderAccountName:
account = DeploymentProviderAccountCreateRequest(
name="production",
provider_key=DeploymentProviderKey.WATSONX_ORCHESTRATE,
- url="https://api.us-south.wxo.cloud.ibm.com",
- provider_data={"api_key": "key"},
+ provider_data={"url": "https://api.us-south.wxo.cloud.ibm.com", "api_key": TEST_API_KEY},
)
assert account.name == "production"
@@ -73,8 +73,7 @@ class TestProviderAccountName:
account = DeploymentProviderAccountCreateRequest(
name=" staging ",
provider_key=DeploymentProviderKey.WATSONX_ORCHESTRATE,
- url="https://api.us-south.wxo.cloud.ibm.com",
- provider_data={"api_key": "key"},
+ provider_data={"url": "https://api.us-south.wxo.cloud.ibm.com", "api_key": TEST_API_KEY},
)
assert account.name == "staging"
@@ -83,8 +82,7 @@ class TestProviderAccountName:
DeploymentProviderAccountCreateRequest(
name="",
provider_key=DeploymentProviderKey.WATSONX_ORCHESTRATE,
- url="https://example.com",
- provider_data={"api_key": "key"},
+ provider_data={"url": "https://example.com", "api_key": TEST_API_KEY},
)
def test_create_rejects_whitespace_only_name(self):
@@ -92,16 +90,14 @@ class TestProviderAccountName:
DeploymentProviderAccountCreateRequest(
name=" ",
provider_key=DeploymentProviderKey.WATSONX_ORCHESTRATE,
- url="https://example.com",
- provider_data={"api_key": "key"},
+ provider_data={"url": "https://example.com", "api_key": TEST_API_KEY},
)
def test_create_rejects_missing_name(self):
with pytest.raises(ValidationError):
DeploymentProviderAccountCreateRequest(
provider_key=DeploymentProviderKey.WATSONX_ORCHESTRATE,
- url="https://example.com",
- provider_data={"api_key": "key"},
+ provider_data={"url": "https://example.com", "api_key": TEST_API_KEY},
)
def test_update_accepts_name(self):
@@ -117,48 +113,23 @@ class TestProviderAccountName:
# ---------------------------------------------------------------------------
-# url validation
+# Provider-data contract boundary
# ---------------------------------------------------------------------------
-class TestProviderUrlSchemaValidation:
- """URL validation for create schema."""
+class TestProviderAccountProviderDataBoundary:
+ """Provider-specific fields belong under provider_data at the API boundary."""
- def test_create_accepts_valid_https_url(self):
+ def test_create_accepts_provider_data_url(self):
account = DeploymentProviderAccountCreateRequest(
name="staging",
provider_key=DeploymentProviderKey.WATSONX_ORCHESTRATE,
- url="https://api.us-south.wxo.cloud.ibm.com/v1",
- provider_data={"api_key": "key"},
+ provider_data={
+ "url": "https://api.us-south.wxo.cloud.ibm.com/v1",
+ "api_key": TEST_API_KEY,
+ },
)
- assert account.url == "https://api.us-south.wxo.cloud.ibm.com/v1"
-
- def test_create_normalizes_scheme_and_host(self):
- account = DeploymentProviderAccountCreateRequest(
- name="staging",
- provider_key=DeploymentProviderKey.WATSONX_ORCHESTRATE,
- url="HTTPS://API.US-SOUTH.WXO.CLOUD.IBM.COM/v1",
- provider_data={"api_key": "key"},
- )
- assert account.url == "https://api.us-south.wxo.cloud.ibm.com/v1"
-
- def test_create_rejects_http(self):
- with pytest.raises(ValidationError, match="https"):
- DeploymentProviderAccountCreateRequest(
- name="staging",
- provider_key=DeploymentProviderKey.WATSONX_ORCHESTRATE,
- url="http://example.com",
- provider_data={"api_key": "key"},
- )
-
- def test_create_rejects_no_scheme(self):
- with pytest.raises(ValidationError, match="https"):
- DeploymentProviderAccountCreateRequest(
- name="staging",
- provider_key=DeploymentProviderKey.WATSONX_ORCHESTRATE,
- url="example.com",
- provider_data={"api_key": "key"},
- )
+ assert account.provider_data["url"] == "https://api.us-south.wxo.cloud.ibm.com/v1"
def test_update_rejects_url_field(self):
with pytest.raises(ValidationError, match="Extra inputs are not permitted"):
@@ -174,8 +145,16 @@ class TestProviderUrlSchemaValidation:
name="staging",
tenant_id="tenant-1",
provider_key=DeploymentProviderKey.WATSONX_ORCHESTRATE,
+ provider_data={"url": "https://api.us-south.wxo.cloud.ibm.com/v1", "api_key": TEST_API_KEY},
+ )
+
+ def test_create_rejects_top_level_url_field(self):
+ with pytest.raises(ValidationError, match="Extra inputs are not permitted"):
+ DeploymentProviderAccountCreateRequest(
+ name="staging",
+ provider_key=DeploymentProviderKey.WATSONX_ORCHESTRATE,
url="https://api.us-south.wxo.cloud.ibm.com/v1",
- provider_data={"api_key": "key"},
+ provider_data={"api_key": TEST_API_KEY},
)
@@ -184,8 +163,7 @@ class TestProviderKeyEnum:
account = DeploymentProviderAccountCreateRequest(
name="staging",
provider_key=DeploymentProviderKey.WATSONX_ORCHESTRATE,
- url="https://api.us-south.wxo.cloud.ibm.com",
- provider_data={"api_key": "key"},
+ provider_data={"url": "https://api.us-south.wxo.cloud.ibm.com", "api_key": TEST_API_KEY},
)
assert account.provider_key == DeploymentProviderKey.WATSONX_ORCHESTRATE
@@ -193,8 +171,7 @@ class TestProviderKeyEnum:
account = DeploymentProviderAccountCreateRequest(
name="staging",
provider_key="watsonx-orchestrate",
- url="https://api.us-south.wxo.cloud.ibm.com",
- provider_data={"api_key": "key"},
+ provider_data={"url": "https://api.us-south.wxo.cloud.ibm.com", "api_key": TEST_API_KEY},
)
assert account.provider_key == DeploymentProviderKey.WATSONX_ORCHESTRATE
@@ -203,8 +180,7 @@ class TestProviderKeyEnum:
DeploymentProviderAccountCreateRequest(
name="staging",
provider_key="unknown-provider",
- url="https://example.com",
- provider_data={"api_key": "key"},
+ provider_data={"url": "https://example.com", "api_key": TEST_API_KEY},
)
def test_rejects_empty_string(self):
@@ -212,8 +188,7 @@ class TestProviderKeyEnum:
DeploymentProviderAccountCreateRequest(
name="staging",
provider_key="",
- url="https://example.com",
- provider_data={"api_key": "key"},
+ provider_data={"url": "https://example.com", "api_key": TEST_API_KEY},
)
diff --git a/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/__tests__/index.test.tsx b/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/__tests__/index.test.tsx
index e138513525..427bc8844c 100644
--- a/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/__tests__/index.test.tsx
+++ b/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/__tests__/index.test.tsx
@@ -99,9 +99,8 @@ const makeProvider = (
): ProviderAccount => ({
id,
name,
- provider_tenant_id: null,
provider_key: providerKey,
- provider_url: "https://wxo.example.com",
+ provider_data: { url: "https://wxo.example.com" },
created_at: null,
updated_at: null,
});
diff --git a/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/hooks/__tests__/use-prepare-deploy.test.ts b/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/hooks/__tests__/use-prepare-deploy.test.ts
index 1e70a9d1e9..3e4adca73f 100644
--- a/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/hooks/__tests__/use-prepare-deploy.test.ts
+++ b/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/hooks/__tests__/use-prepare-deploy.test.ts
@@ -68,9 +68,8 @@ import { usePrepareDeploy } from "../use-prepare-deploy";
const makeProvider = (): ProviderAccount => ({
id: "p1",
name: "WxO Prod",
- provider_tenant_id: null,
provider_key: "watsonx-orchestrate",
- provider_url: "https://wxo.example.com",
+ provider_data: { url: "https://wxo.example.com" },
created_at: null,
updated_at: null,
});
diff --git a/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/index.tsx b/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/index.tsx
index 688aa97ac3..a2da2dc830 100644
--- a/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/index.tsx
+++ b/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/index.tsx
@@ -82,7 +82,7 @@ export default function DeployChoiceDialog({
useGetDeployments(
{
provider_id: selectedProvider?.id ?? "",
- flow_ids: flowId,
+ flow_ids: [flowId],
page: 1,
size: 50,
},
@@ -114,7 +114,7 @@ export default function DeployChoiceDialog({
} = useGetDeploymentAttachments(
{
deploymentId: selectedDeploymentEntry?.id ?? "",
- flow_ids: flowId,
+ flow_ids: [flowId],
},
{ enabled: shouldFetchSelectedDeploymentAttachments },
);
diff --git a/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/provider-phase.tsx b/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/provider-phase.tsx
index d90217b8ad..2225cb96fe 100644
--- a/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/provider-phase.tsx
+++ b/src/frontend/src/components/core/flowToolbarComponent/components/deploy-choice-dialog/provider-phase.tsx
@@ -49,7 +49,9 @@ export default function ProviderPhaseContent({
>
{provider.name}
- {provider.url}
+ {typeof provider.provider_data?.url === "string"
+ ? provider.provider_data.url
+ : "—"}
diff --git a/src/frontend/src/controllers/API/queries/deployment-provider-accounts/__tests__/use-get-provider-accounts.test.ts b/src/frontend/src/controllers/API/queries/deployment-provider-accounts/__tests__/use-get-provider-accounts.test.ts
index 0f53b12dfc..dea6ea0621 100644
--- a/src/frontend/src/controllers/API/queries/deployment-provider-accounts/__tests__/use-get-provider-accounts.test.ts
+++ b/src/frontend/src/controllers/API/queries/deployment-provider-accounts/__tests__/use-get-provider-accounts.test.ts
@@ -78,9 +78,11 @@ describe("useGetProviderAccounts", () => {
{
id: "prov-1",
name: "My WxO",
- provider_tenant_id: "tenant-1",
- provider_key: "watsonx_orchestrate",
- provider_url: "https://api.wxo.ibm.com",
+ provider_key: "watsonx-orchestrate",
+ provider_data: {
+ url: "https://api.wxo.ibm.com",
+ tenant_id: "tenant-1",
+ },
created_at: "2026-01-01T00:00:00Z",
updated_at: null,
},
@@ -96,10 +98,12 @@ describe("useGetProviderAccounts", () => {
expect(result.data).toBeDefined();
if (!result.data) return;
- expect(result.data).toEqual(responseData);
expect(result.data.provider_accounts).toHaveLength(1);
expect(result.data.provider_accounts[0].provider_key).toBe(
- "watsonx_orchestrate",
+ "watsonx-orchestrate",
+ );
+ expect(result.data.provider_accounts[0].provider_data?.url).toBe(
+ "https://api.wxo.ibm.com",
);
});
});
diff --git a/src/frontend/src/controllers/API/queries/deployment-provider-accounts/__tests__/use-post-provider-account.test.ts b/src/frontend/src/controllers/API/queries/deployment-provider-accounts/__tests__/use-post-provider-account.test.ts
index 52a76b53b0..8e73186e43 100644
--- a/src/frontend/src/controllers/API/queries/deployment-provider-accounts/__tests__/use-post-provider-account.test.ts
+++ b/src/frontend/src/controllers/API/queries/deployment-provider-accounts/__tests__/use-post-provider-account.test.ts
@@ -40,9 +40,11 @@ describe("usePostProviderAccount", () => {
const validPayload: ProviderAccountCreateRequest = {
name: "My WxO Environment",
- provider_key: "watsonx_orchestrate",
- provider_url: "https://api.wxo.ibm.com",
- provider_data: { api_key: "secret-key" }, // pragma: allowlist secret
+ provider_key: "watsonx-orchestrate",
+ provider_data: {
+ url: "https://api.wxo.ibm.com",
+ api_key: "secret-key", // pragma: allowlist secret
+ },
};
it("posts to providers endpoint with full payload", async () => {
@@ -91,9 +93,8 @@ describe("usePostProviderAccount", () => {
const created = {
id: "prov-1",
name: "My WxO Environment",
- provider_key: "watsonx_orchestrate",
- provider_url: "https://api.wxo.ibm.com",
- provider_tenant_id: "tenant-1",
+ provider_key: "watsonx-orchestrate",
+ provider_data: { url: "https://api.wxo.ibm.com", tenant_id: "tenant-1" },
created_at: "2026-01-01T00:00:00Z",
updated_at: null,
};
diff --git a/src/frontend/src/controllers/API/queries/deployment-provider-accounts/use-post-provider-account.ts b/src/frontend/src/controllers/API/queries/deployment-provider-accounts/use-post-provider-account.ts
index e8174403f4..d23d749629 100644
--- a/src/frontend/src/controllers/API/queries/deployment-provider-accounts/use-post-provider-account.ts
+++ b/src/frontend/src/controllers/API/queries/deployment-provider-accounts/use-post-provider-account.ts
@@ -7,8 +7,8 @@ import { UseRequestProcessor } from "../../services/request-processor";
export interface ProviderAccountCreateRequest {
name: string;
provider_key: string;
- url: string;
provider_data: {
+ url: string;
api_key: string;
};
}
diff --git a/src/frontend/src/controllers/API/queries/deployments/__tests__/use-get-deployment-attachments.test.ts b/src/frontend/src/controllers/API/queries/deployments/__tests__/use-get-deployment-attachments.test.ts
index d7ed65e672..252382c556 100644
--- a/src/frontend/src/controllers/API/queries/deployments/__tests__/use-get-deployment-attachments.test.ts
+++ b/src/frontend/src/controllers/API/queries/deployments/__tests__/use-get-deployment-attachments.test.ts
@@ -44,6 +44,7 @@ describe("useGetDeploymentAttachments", () => {
expect(mockApiGet).toHaveBeenCalledWith("/api/v1/deployments/dep-1/flows", {
params: { size: 50 },
+ paramsSerializer: { indexes: null },
});
});
diff --git a/src/frontend/src/controllers/API/queries/deployments/__tests__/use-get-deployments-by-providers.test.ts b/src/frontend/src/controllers/API/queries/deployments/__tests__/use-get-deployments-by-providers.test.ts
index c47e27cf8f..1488547b2b 100644
--- a/src/frontend/src/controllers/API/queries/deployments/__tests__/use-get-deployments-by-providers.test.ts
+++ b/src/frontend/src/controllers/API/queries/deployments/__tests__/use-get-deployments-by-providers.test.ts
@@ -71,22 +71,22 @@ describe("useGetDeploymentsByProviders", () => {
});
});
- it("combine merges deployments and injects provider_account_id", () => {
+ it("combine merges deployments and preserves provider_id", () => {
useGetDeploymentsByProviders(["prov-1", "prov-2"]);
const mockResults = [
{
data: {
deployments: [
- { id: "d1", name: "Agent 1" },
- { id: "d2", name: "Agent 2" },
+ { id: "d1", name: "Agent 1", provider_id: "prov-1" },
+ { id: "d2", name: "Agent 2", provider_id: "prov-1" },
],
},
isLoading: false,
},
{
data: {
- deployments: [{ id: "d3", name: "Agent 3" }],
+ deployments: [{ id: "d3", name: "Agent 3", provider_id: "prov-2" }],
},
isLoading: false,
},
@@ -96,10 +96,10 @@ describe("useGetDeploymentsByProviders", () => {
expect(combined.deployments).toHaveLength(3);
expect(combined.deployments[0]).toEqual(
- expect.objectContaining({ id: "d1", provider_account_id: "prov-1" }),
+ expect.objectContaining({ id: "d1", provider_id: "prov-1" }),
);
expect(combined.deployments[2]).toEqual(
- expect.objectContaining({ id: "d3", provider_account_id: "prov-2" }),
+ expect.objectContaining({ id: "d3", provider_id: "prov-2" }),
);
expect(combined.isLoading).toBe(false);
});
@@ -123,7 +123,9 @@ describe("useGetDeploymentsByProviders", () => {
const mockResults = [
{ data: undefined, isLoading: true },
{
- data: { deployments: [{ id: "d1", name: "Agent" }] },
+ data: {
+ deployments: [{ id: "d1", name: "Agent", provider_id: "prov-2" }],
+ },
isLoading: false,
},
];
@@ -131,7 +133,7 @@ describe("useGetDeploymentsByProviders", () => {
const combined = capturedConfig.combine(mockResults);
expect(combined.deployments).toHaveLength(1);
- expect(combined.deployments[0].provider_account_id).toBe("prov-2");
+ expect(combined.deployments[0].provider_id).toBe("prov-2");
});
it("returns empty deployments for empty provider list", () => {
diff --git a/src/frontend/src/controllers/API/queries/deployments/__tests__/use-get-deployments.test.ts b/src/frontend/src/controllers/API/queries/deployments/__tests__/use-get-deployments.test.ts
index 9ec7771e61..88940b64c5 100644
--- a/src/frontend/src/controllers/API/queries/deployments/__tests__/use-get-deployments.test.ts
+++ b/src/frontend/src/controllers/API/queries/deployments/__tests__/use-get-deployments.test.ts
@@ -47,10 +47,10 @@ describe("useGetDeployments", () => {
expect(mockApiGet).toHaveBeenCalledWith("/api/v1/deployments", {
params: {
provider_id: "prov-1",
- flow_ids: undefined,
page: 1,
size: 20,
},
+ paramsSerializer: { indexes: null },
});
});
@@ -61,14 +61,20 @@ describe("useGetDeployments", () => {
useGetDeployments({
provider_id: "prov-1",
- flow_ids: "f1,f2",
+ flow_ids: ["f1", "f2"],
page: 2,
size: 10,
});
await flushPromises();
expect(mockApiGet).toHaveBeenCalledWith("/api/v1/deployments", {
- params: { provider_id: "prov-1", flow_ids: "f1,f2", page: 2, size: 10 },
+ params: {
+ provider_id: "prov-1",
+ flow_ids: ["f1", "f2"],
+ page: 2,
+ size: 10,
+ },
+ paramsSerializer: { indexes: null },
});
});
@@ -77,7 +83,7 @@ describe("useGetDeployments", () => {
useGetDeployments({
provider_id: "prov-1",
- flow_ids: "f1",
+ flow_ids: ["f1"],
page: 3,
size: 5,
});
@@ -85,7 +91,7 @@ describe("useGetDeployments", () => {
expect(mockQuery).toHaveBeenCalledWith(
[
"useGetDeployments",
- { provider_id: "prov-1", flow_ids: "f1", page: 3, size: 5 },
+ { provider_id: "prov-1", flow_ids: ["f1"], page: 3, size: 5 },
],
expect.any(Function),
undefined,
diff --git a/src/frontend/src/controllers/API/queries/deployments/__tests__/use-post-deployment.test.ts b/src/frontend/src/controllers/API/queries/deployments/__tests__/use-post-deployment.test.ts
index 3d5bcd50bc..67507e2fd3 100644
--- a/src/frontend/src/controllers/API/queries/deployments/__tests__/use-post-deployment.test.ts
+++ b/src/frontend/src/controllers/API/queries/deployments/__tests__/use-post-deployment.test.ts
@@ -36,27 +36,24 @@ describe("usePostDeployment", () => {
const validPayload: DeploymentCreateRequest = {
provider_id: "prov-1",
- spec: { name: "My Agent", description: "A test agent", type: "agent" },
+ name: "My Agent",
+ description: "A test agent",
+ type: "agent",
provider_data: {
llm: "ibm/granite-3-8b-instruct",
- operations: [
+ add_flows: [
{
- op: "bind",
flow_version_id: "fv-1",
app_ids: ["app-1"],
tool_name: "my_tool",
},
],
- connections: {
- raw_payloads: [
- {
- app_id: "app-1",
- environment_variables: {
- API_KEY: { value: "secret", source: "raw" },
- },
- },
- ],
- },
+ connections: [
+ {
+ app_id: "app-1",
+ credentials: [{ key: "API_KEY", value: "secret", source: "raw" }],
+ },
+ ],
},
};
@@ -83,14 +80,12 @@ describe("usePostDeployment", () => {
});
});
- it("sends operations without tool_name when not provided", async () => {
+ it("sends add_flows without tool_name when not provided", async () => {
const payloadNoToolName: DeploymentCreateRequest = {
...validPayload,
provider_data: {
...validPayload.provider_data,
- operations: [
- { op: "bind", flow_version_id: "fv-1", app_ids: ["app-1"] },
- ],
+ add_flows: [{ flow_version_id: "fv-1", app_ids: ["app-1"] }],
},
};
mockApiPost.mockResolvedValue({ data: { id: "dep-1" } });
@@ -99,6 +94,6 @@ describe("usePostDeployment", () => {
await mutation.mutate(payloadNoToolName);
const sentPayload = mockApiPost.mock.calls[0][1];
- expect(sentPayload.provider_data.operations[0].tool_name).toBeUndefined();
+ expect(sentPayload.provider_data.add_flows[0].tool_name).toBeUndefined();
});
});
diff --git a/src/frontend/src/controllers/API/queries/deployments/use-get-deployment-attachments.ts b/src/frontend/src/controllers/API/queries/deployments/use-get-deployment-attachments.ts
index f8e1030da9..24bf80c653 100644
--- a/src/frontend/src/controllers/API/queries/deployments/use-get-deployment-attachments.ts
+++ b/src/frontend/src/controllers/API/queries/deployments/use-get-deployment-attachments.ts
@@ -9,12 +9,14 @@ import { UseRequestProcessor } from "../../services/request-processor";
* Identity contract: Langflow tracks provider tools by their immutable
* `provider_snapshot_id` (wxO tool_id), never by name.
* - Tool renamed in provider → same snapshot ID, new `provider_data.tool_name`.
- * - Tool deleted in provider → snapshot ID unresolvable, `provider_data.tool_name` is null/missing.
+ * - Tool deleted in provider → missing from snapshot list, so
+ * `provider_data` is null for that attachment.
* - Tool deleted + new tool created with same name → different ID, our
* attachment still points to the old (missing) ID. The new tool is
* invisible to Langflow until explicitly attached.
*
- * Use `provider_data.tool_name` for display, fall back to `flow_name` when null.
+ * When `provider_data` is non-null, `tool_name` is always present.
+ * Fall back to `flow_name` when `provider_data` is null.
* Use `provider_snapshot_id` for operations.
*/
export interface DeploymentFlowVersionItem {
@@ -26,8 +28,7 @@ export interface DeploymentFlowVersionItem {
provider_snapshot_id: string | null;
provider_data: {
app_ids?: string[];
- /** Provider tool name — null/missing when the tool was deleted or provider is unreachable. */
- tool_name?: string | null;
+ tool_name: string;
} | null;
}
@@ -40,7 +41,7 @@ export interface DeploymentFlowVersionListResponse {
interface GetDeploymentAttachmentsParams {
deploymentId: string;
- flow_ids?: string;
+ flow_ids?: string[];
}
export const useGetDeploymentAttachments: useQueryFunctionType<
@@ -50,9 +51,16 @@ export const useGetDeploymentAttachments: useQueryFunctionType<
const { query } = UseRequestProcessor();
const fn = async (): Promise => {
+ const params = {
+ size: 50,
+ ...(flow_ids && flow_ids.length > 0 ? { flow_ids } : {}),
+ };
const { data } = await api.get(
`${getURL("DEPLOYMENTS")}/${deploymentId}/flows`,
- { params: { size: 50, flow_ids } },
+ {
+ params,
+ paramsSerializer: { indexes: null },
+ },
);
return data;
};
diff --git a/src/frontend/src/controllers/API/queries/deployments/use-get-deployments-by-providers.ts b/src/frontend/src/controllers/API/queries/deployments/use-get-deployments-by-providers.ts
index 90ed1e941b..e682ab3680 100644
--- a/src/frontend/src/controllers/API/queries/deployments/use-get-deployments-by-providers.ts
+++ b/src/frontend/src/controllers/API/queries/deployments/use-get-deployments-by-providers.ts
@@ -30,13 +30,10 @@ export function useGetDeploymentsByProviders(
})),
combine: (results): UseGetDeploymentsByProvidersResult => {
const merged: Deployment[] = [];
- for (let i = 0; i < results.length; i++) {
- const data = results[i].data;
+ for (const result of results) {
+ const data = result.data;
if (data?.deployments) {
- const pid = providerIds[i];
- for (const dep of data.deployments) {
- merged.push({ ...dep, provider_account_id: pid });
- }
+ merged.push(...data.deployments);
}
}
return {
diff --git a/src/frontend/src/controllers/API/queries/deployments/use-get-deployments.ts b/src/frontend/src/controllers/API/queries/deployments/use-get-deployments.ts
index c6aeaf3351..6dff5cefbb 100644
--- a/src/frontend/src/controllers/API/queries/deployments/use-get-deployments.ts
+++ b/src/frontend/src/controllers/API/queries/deployments/use-get-deployments.ts
@@ -13,7 +13,7 @@ export interface DeploymentListResponse {
interface GetDeploymentsParams {
provider_id: string;
- flow_ids?: string;
+ flow_ids?: string[];
page?: number;
size?: number;
}
@@ -25,9 +25,18 @@ export const useGetDeployments: useQueryFunctionType<
const { query } = UseRequestProcessor();
const getDeploymentsFn = async (): Promise => {
+ const params = {
+ provider_id,
+ ...(flow_ids && flow_ids.length > 0 ? { flow_ids } : {}),
+ page,
+ size,
+ };
const { data } = await api.get(
`${getURL("DEPLOYMENTS")}`,
- { params: { provider_id, flow_ids, page, size } },
+ {
+ params,
+ paramsSerializer: { indexes: null },
+ },
);
return data;
};
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/add-provider-modal.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/add-provider-modal.test.tsx
index 55061af6b9..a7a8050ecb 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/add-provider-modal.test.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/add-provider-modal.test.tsx
@@ -163,8 +163,10 @@ describe("Submit behavior", () => {
expect(mockMutateAsync).toHaveBeenCalledWith({
name: "My Env",
provider_key: "watsonx-orchestrate",
- url: "https://prod.example.com",
- provider_data: { api_key: "sk-test-123" }, // pragma: allowlist secret
+ provider_data: {
+ url: "https://prod.example.com",
+ api_key: "sk-test-123", // pragma: allowlist secret
+ },
});
});
expect(setOpen).toHaveBeenCalledWith(false);
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/create-mode.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/create-mode.test.tsx
index a6e0a2f1ce..f61237999e 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/create-mode.test.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/create-mode.test.tsx
@@ -534,8 +534,10 @@ describe("Create mode — buildProviderAccountPayload", () => {
expect(payload).toEqual({
name: "My Account",
provider_key: "watsonx-orchestrate",
- url: "https://api.example.com",
- provider_data: { api_key: "secret-key-123" }, // pragma: allowlist secret
+ provider_data: {
+ url: "https://api.example.com",
+ api_key: "secret-key-123", // pragma: allowlist secret
+ },
});
});
@@ -555,7 +557,7 @@ describe("Create mode — buildProviderAccountPayload", () => {
expect(payload).toBeDefined();
if (!payload) return;
expect(payload.name).toBe("padded");
- expect(payload.url).toBe("https://padded.com");
+ expect(payload.provider_data.url).toBe("https://padded.com");
expect(payload.provider_data.api_key).toBe("padded-key"); // pragma: allowlist secret
});
});
@@ -596,7 +598,7 @@ describe("Create mode — buildDeploymentPayload", () => {
expect(payload.provider_data.llm).toBe("granite-3b");
});
- it("builds bind operations for each attached flow", () => {
+ it("builds add_flows entries for each attached flow", () => {
const { result } = renderCreateHook();
act(() => {
@@ -748,7 +750,7 @@ describe("Create mode — buildDeploymentPayload", () => {
expect(payload.provider_data.connections).toEqual([]);
});
- it("returns empty operations when no flows attached", () => {
+ it("returns empty add_flows when no flows attached", () => {
const { result } = renderCreateHook();
act(() => {
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployment-details-modal.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployment-details-modal.test.tsx
index ab7745d063..4e6758c7fd 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployment-details-modal.test.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployment-details-modal.test.tsx
@@ -67,6 +67,7 @@ import DeploymentDetailsModal from "../components/deployment-details-modal/deplo
const makeDeployment = (overrides: Partial = {}): Deployment => ({
id: "dep-1",
+ provider_id: "prov-1",
name: "My Agent",
description: "A sales agent",
type: "agent",
@@ -75,7 +76,6 @@ const makeDeployment = (overrides: Partial = {}): Deployment => ({
provider_data: { llm: "granite-13b-chat" },
resource_key: "rk-1",
attached_count: 2,
- provider_account_id: "prov-1",
...overrides,
});
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployment-stepper-modal.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployment-stepper-modal.test.tsx
index 76d5d30fb1..4640affa27 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployment-stepper-modal.test.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployment-stepper-modal.test.tsx
@@ -172,6 +172,7 @@ import DeploymentStepperModal from "../components/deployment-stepper-modal";
const makeDeployment = (overrides: Partial = {}): Deployment => ({
id: "dep-1",
+ provider_id: "prov-1",
name: "My Agent",
description: "A sales agent",
type: "agent",
@@ -181,7 +182,6 @@ const makeDeployment = (overrides: Partial = {}): Deployment => ({
resource_key: "rk-1",
attached_count: 2,
matched_attachments: null,
- provider_account_id: "prov-1",
...overrides,
});
@@ -190,9 +190,11 @@ const makeInstance = (
): ProviderAccount => ({
id: "inst-1",
name: "Prod Instance",
- provider_tenant_id: "tenant-1",
provider_key: "watsonx-orchestrate",
- provider_url: "https://api.example.com",
+ provider_data: {
+ tenant_id: "tenant-1",
+ url: "https://api.example.com",
+ },
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
...overrides,
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployments-table.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployments-table.test.tsx
index ff525f98b8..0f6b2e4e6d 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployments-table.test.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/deployments-table.test.tsx
@@ -25,6 +25,7 @@ import DeploymentsTable from "../components/deployments-table";
const makeDeployment = (overrides: Partial = {}): Deployment => ({
id: "dep-1",
+ provider_id: "prov-1",
name: "My Agent",
description: null,
type: "agent",
@@ -34,7 +35,6 @@ const makeDeployment = (overrides: Partial = {}): Deployment => ({
resource_key: "rk-1",
attached_count: 2,
matched_attachments: null,
- provider_account_id: "prov-1",
...overrides,
});
@@ -94,7 +94,7 @@ describe("Row rendering", () => {
});
it("shows dash when provider is unknown", () => {
- renderTable([makeDeployment({ provider_account_id: "unknown" })]);
+ renderTable([makeDeployment({ provider_id: "unknown" })]);
expect(screen.getByText("—")).toBeInTheDocument();
});
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/providers-table.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/providers-table.test.tsx
index e40ce4f591..da3e89c9ba 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/providers-table.test.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/providers-table.test.tsx
@@ -21,7 +21,7 @@ const makeProvider = (
id: "prov-1",
name: "Production WxO",
provider_key: "watsonx-orchestrate",
- url: "https://api.example.com",
+ provider_data: { url: "https://api.example.com" },
created_at: "2025-05-01T00:00:00Z",
updated_at: "2025-05-10T00:00:00Z",
...overrides,
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-provider.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-provider.test.tsx
index 436a7f6494..f945fd5696 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-provider.test.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/__tests__/step-provider.test.tsx
@@ -54,7 +54,7 @@ const makeEnvironment = (
id: "env-1",
name: "Prod Environment",
provider_key: "watsonx-orchestrate",
- url: "https://api.prod.example.com",
+ provider_data: { url: "https://api.prod.example.com" },
created_at: "2025-05-01T00:00:00Z",
updated_at: "2025-05-01T00:00:00Z",
...overrides,
@@ -127,7 +127,7 @@ describe("With existing environments", () => {
makeEnvironment({
id: "env-2",
name: "Staging",
- url: "https://api.staging.example.com",
+ provider_data: { url: "https://api.staging.example.com" },
}),
],
};
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/add-provider-modal.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/add-provider-modal.tsx
index d2d7b88fc9..5a75bed187 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/add-provider-modal.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/add-provider-modal.tsx
@@ -54,8 +54,10 @@ export default function AddProviderModal({
await createProviderAccount({
name: credentials.name.trim(),
provider_key: credentials.provider_key,
- url: credentials.url.trim(),
- provider_data: { api_key: credentials.api_key.trim() },
+ provider_data: {
+ url: credentials.url.trim(),
+ api_key: credentials.api_key.trim(),
+ },
});
setCredentials(EMPTY_CREDENTIALS);
setOpen(false);
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployment-details-modal/deployment-details-modal.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployment-details-modal/deployment-details-modal.tsx
index a3d29e5ff5..b26f7cbec3 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployment-details-modal/deployment-details-modal.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployment-details-modal/deployment-details-modal.tsx
@@ -43,7 +43,7 @@ export default function DeploymentDetailsModal({
{ enabled: open && !!deploymentId, refetchOnWindowFocus: false },
);
- const providerId = deployment?.provider_account_id ?? "";
+ const providerId = details?.provider_id ?? deployment?.provider_id ?? "";
const { data: configsData, isFetching: isFetchingConfigs } =
useGetDeploymentConfigs(
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployment-stepper-modal.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployment-stepper-modal.tsx
index ec4ee6d5f9..672f51baa2 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployment-stepper-modal.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployment-stepper-modal.tsx
@@ -66,16 +66,16 @@ export default function DeploymentStepperModal({
);
// Build initial maps from attachments for the stepper context.
- // Tool names and connection bindings come from the provider (wxO) via
+ // Tool names and connection assignments come from the provider (wxO) via
// the /flows endpoint, NOT from the Langflow database. This means:
//
// - If a user renames a tool in the wxO console, the new name appears
// here on the next edit. Langflow doesn't cache tool names locally.
- // - If a tool is deleted in wxO, provider_data.tool_name will be null and the
+ // - If a tool is deleted in wxO, provider_data will be null and the
// review page falls back to the Langflow flow name.
// - If a connection is deleted in wxO but the tool still references it,
// the app_id will appear in connectionsByFlow. The backend will fail
- // fast during the update if the caller tries to bind a new tool to
+ // fast during the update if the caller tries to attach a new tool to
// that stale connection.
const editInitialState = useMemo(() => {
if (!isEditMode || !attachmentsData?.flow_versions) return null;
@@ -97,7 +97,7 @@ export default function DeploymentStepperModal({
if (providerToolName) {
toolNames.set(fv.flow_id, providerToolName);
}
- // Pre-populate attached connections from existing tool bindings.
+ // Pre-populate attached connections from existing tool assignments.
const appIds = fv.provider_data?.app_ids;
if (appIds && appIds.length > 0) {
connectionsByFlow.set(fv.flow_id, appIds);
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployments-content.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployments-content.tsx
index 4679b16e95..e0b52f207b 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployments-content.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployments-content.tsx
@@ -9,7 +9,7 @@ import {
import { useDeleteDeployment } from "@/controllers/API/queries/deployments/use-delete-deployment";
import { useDeleteWithConfirmation } from "../hooks/use-delete-with-confirmation";
import { useTestDeploymentModal } from "../hooks/use-test-deployment-modal";
-import type { Deployment, ProviderAccount } from "../types";
+import { type Deployment, type ProviderAccount } from "../types";
import DeploymentDetailsModal from "./deployment-details-modal/deployment-details-modal";
import DeploymentStepperModal from "./deployment-stepper-modal";
import DeploymentsEmptyState from "./deployments-empty-state";
@@ -112,10 +112,8 @@ export default function DeploymentsContent({
onTestDeployment={testModal.handleTestFromStepper}
editingDeployment={editingDeployment}
initialInstance={
- editingDeployment?.provider_account_id
- ? providers.find(
- (p) => p.id === editingDeployment.provider_account_id,
- )
+ editingDeployment?.provider_id
+ ? providers.find((p) => p.id === editingDeployment.provider_id)
: undefined
}
/>
@@ -135,7 +133,7 @@ export default function DeploymentsContent({
deployment={detailsDeployment}
providerName={
detailsDeployment
- ? (providerMap[detailsDeployment.provider_account_id ?? ""] ?? "—")
+ ? (providerMap[detailsDeployment.provider_id ?? ""] ?? "—")
: ""
}
/>
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployments-table.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployments-table.tsx
index aab5c5ce18..a19e04775d 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployments-table.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/deployments-table.tsx
@@ -19,7 +19,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { cn } from "@/utils/utils";
-import type { Deployment, DeploymentType } from "../types";
+import { type Deployment, type DeploymentType } from "../types";
import DeploymentExpandedRow from "./deployment-expanded-row";
interface DeploymentsTableProps {
@@ -158,7 +158,7 @@ export default function DeploymentsTable({
- {providerMap[deployment.provider_account_id ?? ""] ?? "—"}
+ {providerMap[deployment.provider_id ?? ""] ?? "—"}
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/providers-table.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/providers-table.tsx
index fb0d6b8760..37f7c7bd5b 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/providers-table.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/providers-table.tsx
@@ -63,7 +63,9 @@ export default function ProvidersTable({
- {provider.url}
+ {typeof provider.provider_data?.url === "string"
+ ? provider.provider_data.url
+ : "—"}
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-provider.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-provider.tsx
index e8a4f7dc95..9ccf46b001 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-provider.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/step-provider.tsx
@@ -86,7 +86,9 @@ function EnvironmentList({
{environment.name}
- {environment.url}
+ {typeof environment.provider_data?.url === "string"
+ ? environment.provider_data.url
+ : "—"}
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/contexts/deployment-stepper-context.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/contexts/deployment-stepper-context.tsx
index bbbbfc8f78..d65238b8fd 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/contexts/deployment-stepper-context.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/contexts/deployment-stepper-context.tsx
@@ -42,7 +42,7 @@ interface DeploymentStepperInitialState {
initialLlm?: string;
/** Pre-populated tool names from provider (edit mode). Key = flowId. */
initialToolNameByFlow?: Map;
- /** Pre-populated connection bindings from provider (edit mode). Key = flowId. */
+ /** Pre-populated connection assignments from provider (edit mode). Key = flowId. */
initialConnectionsByFlow?: Map;
}
@@ -302,8 +302,10 @@ export function DeploymentStepperProvider({
return {
name: credentials.name.trim(),
provider_key: "watsonx-orchestrate",
- url: credentials.url.trim(),
- provider_data: { api_key: credentials.api_key.trim() },
+ provider_data: {
+ url: credentials.url.trim(),
+ api_key: credentials.api_key.trim(),
+ },
};
}, [credentials, hasValidCredentials]);
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/__tests__/use-provider-filter.test.ts b/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/__tests__/use-provider-filter.test.ts
index c5a6e0c89a..1a3b13719a 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/__tests__/use-provider-filter.test.ts
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/__tests__/use-provider-filter.test.ts
@@ -11,9 +11,11 @@ const makeProvider = (
): ProviderAccount => ({
id: "p1",
name: "Prod Environment",
- provider_tenant_id: "tenant-1",
provider_key: "watsonx-orchestrate",
- provider_url: "https://api.example.com",
+ provider_data: {
+ tenant_id: "tenant-1",
+ url: "https://api.example.com",
+ },
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
...overrides,
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/__tests__/use-test-deployment-modal.test.tsx b/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/__tests__/use-test-deployment-modal.test.tsx
index 568ce59579..07f8421f4b 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/__tests__/use-test-deployment-modal.test.tsx
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/__tests__/use-test-deployment-modal.test.tsx
@@ -12,7 +12,7 @@ const makeDeployment = (overrides: Partial = {}): Deployment =>
({
id: "dep-1",
name: "My Deployment",
- provider_account_id: "prov-1",
+ provider_id: "prov-1",
...overrides,
}) as Deployment;
@@ -51,7 +51,7 @@ describe("useTestDeploymentModal", () => {
const deployment = makeDeployment({
id: "d1",
name: "Bot",
- provider_account_id: "p1",
+ provider_id: "p1",
});
act(() => {
@@ -64,28 +64,28 @@ describe("useTestDeploymentModal", () => {
});
});
- it("sets testProviderId from deployment.provider_account_id", () => {
+ it("sets testProviderId from deployment.provider_id", () => {
const { result } = renderHook(() => useTestDeploymentModal(), {
wrapper: withRouter(),
});
act(() => {
result.current.handleTestDeployment(
- makeDeployment({ provider_account_id: "prov-99" }),
+ makeDeployment({ provider_id: "prov-99" }),
);
});
expect(result.current.testProviderId).toBe("prov-99");
});
- it("uses empty string for testProviderId when provider_account_id is null", () => {
+ it("uses empty string for testProviderId when provider_id is null", () => {
const { result } = renderHook(() => useTestDeploymentModal(), {
wrapper: withRouter(),
});
act(() => {
result.current.handleTestDeployment(
- makeDeployment({ provider_account_id: null as unknown as string }),
+ makeDeployment({ provider_id: null as unknown as string }),
);
});
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/use-test-deployment-modal.ts b/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/use-test-deployment-modal.ts
index 0adef538ab..6db0ec21a1 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/use-test-deployment-modal.ts
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/hooks/use-test-deployment-modal.ts
@@ -41,7 +41,7 @@ export function useTestDeploymentModal(): TestDeploymentModal {
const handleTestDeployment = useCallback((deployment: Deployment) => {
setTestTarget(deployment);
- setTestProviderId(deployment.provider_account_id ?? "");
+ setTestProviderId(deployment.provider_id ?? "");
}, []);
const handleTestFromStepper = useCallback(
diff --git a/src/frontend/src/pages/MainPage/pages/deploymentsPage/types.ts b/src/frontend/src/pages/MainPage/pages/deploymentsPage/types.ts
index 8ca696e303..c9bffaadb5 100644
--- a/src/frontend/src/pages/MainPage/pages/deploymentsPage/types.ts
+++ b/src/frontend/src/pages/MainPage/pages/deploymentsPage/types.ts
@@ -27,8 +27,7 @@ export interface ProviderAccount {
id: string;
name: string;
provider_key: string;
- url: string;
- provider_data?: Record;
+ provider_data?: Record | null;
created_at: string | null;
updated_at: string | null;
}
@@ -44,6 +43,7 @@ export type DeploymentType = "agent" | "mcp";
export interface Deployment {
id: string;
+ provider_id?: string;
name: string;
description?: string;
type: DeploymentType;
@@ -53,8 +53,6 @@ export interface Deployment {
resource_key: string;
attached_count: number;
flow_version_ids?: string[];
- /** Populated client-side when merging deployments from multiple providers. */
- provider_account_id?: string;
}
export interface SnapshotUpdateResponse {
diff --git a/src/frontend/tests/utils/deployment-mocks.ts b/src/frontend/tests/utils/deployment-mocks.ts
index d3d173edb5..3d7db9eea5 100644
--- a/src/frontend/tests/utils/deployment-mocks.ts
+++ b/src/frontend/tests/utils/deployment-mocks.ts
@@ -29,8 +29,16 @@ export const NEW_PROVIDER = {
export const DEPLOYMENT = {
id: "dep-1",
+ provider_id: "prov-1",
name: "Test Deployment",
+ description: "Mock deployment for E2E tests",
type: "agent",
+ created_at: "2026-04-06T00:00:00Z",
+ updated_at: "2026-04-06T00:00:00Z",
+ resource_key: "dep-1-resource",
+ attached_count: 1,
+ flow_version_ids: ["fv1"],
+ // Keep legacy keys to avoid breaking any tests still reading them.
provider_account_id: "prov-1",
provider_account_name: "My Env",
status: "deployed",
@@ -102,6 +110,7 @@ export const FLOW_VERSIONS_MOCK = {
export const DEPLOY_RESPONSE = {
id: "dep-new",
+ provider_id: "prov-1",
name: "My Deployment",
type: "agent",
provider_account_id: "prov-1",