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",