mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 06:10:49 +08:00
feat: wxo api provider data url for provider accounts (#12607)
* feat: deployment page list
* feat: add deployment stepper modal with context-based state management
Implement a multi-step deployment creation flow with 4 steps (Provider,
Type, Attach Flows, Review). State is centralized in a scoped
DeploymentStepperContext to avoid prop-drilling across step components.
Includes bug fixes for version re-selection and connection pre-selection.
* feat: replace mock flows and versions with real API data in deployment stepper
Fetch real flows from the API (scoped to current folder) and versions
per-flow lazily when selected. Enrich selectedVersionByFlow context to
store versionTag alongside versionId so the review step can display it
without re-fetching. Remove MOCK_FLOWS_WITH_VERSIONS from mock-data.
* refactor: improve deployment stepper code quality and conventions
Rename all new deployment files to kebab-case per project conventions,
fix context re-render issues with useMemo/useCallback, replace raw
Tailwind colors with design tokens, add missing data-testid and ARIA
attributes, fix Deploy button disabled on review step, and move shared
FlowTabType to a dedicated types file.
* refactor: align deployment provider types and UI to backend contracts
- Rename ProviderInstance -> ProviderAccount to match backend naming
- Update ProviderAccount fields to match DeploymentProviderAccountGetResponse (provider_key, provider_url, provider_tenant_id, created_at, updated_at)
- Update ProviderCredentials fields to match DeploymentProviderAccountCreateRequest (provider_key, provider_url, api_key)
- Rename all "instance" UI terminology to "environment" (tabs, labels, components)
- Auto-select watsonx Orchestrate on mount as the only supported provider
- Remove Kubernetes from mock providers; update mock data to use watsonx_orchestrate only
* feat: add react-query hooks for deployments and provider accounts with mock data
- Add useGetProviderAccounts hook (GET /deployments/providers) replacing direct MOCK_PROVIDER_INSTANCES import in step-provider.tsx
- Add useGetDeployments hook (GET /deployments) replacing direct MOCK_DEPLOYMENTS import in deployments-page.tsx
- Replace fake setTimeout loading state with hook isLoading state
- Register DEPLOYMENTS and DEPLOYMENT_PROVIDER_ACCOUNTS URL constants
- Real API calls are commented out with TODO markers for easy swap when backend is ready
* chore: remove stale biome-ignore suppression in step-attach-flows
* fix: replace button role=radio with semantic input type=radio in ProviderCard
* fix: replace button role=radio with semantic radio inputs across deployment stepper
- Extract RadioSelectItem component (label + sr-only input + checkbox indicator)
used by version, connection, and environment selectors
- Fix step-type.tsx type cards with label/input pattern (matching ProviderCard)
- Add role="radiogroup" wrapper to EnvironmentList
- Fix envVars key: use stable crypto.randomUUID() id instead of array index
* feat: add name field to provider accounts, multi-select connections, and real API call
- Add `name` field to ProviderAccount, ProviderCredentials, and mock data
- Switch connection selection from single to multi-select (CheckboxSelectItem)
- Allow creating new connections inline from the attach flows step
- Lift connections state up to DeploymentStepperContext
- Move ConnectionItem type from step-attach-flows to shared types.ts
- Wire up real API call in useGetProviderAccounts (remove mock)
* feat: wire deploy button and populate deployments page with real API data
- Add usePostDeployment and usePostProviderAccount mutation hooks
- Wire Deploy button in stepper modal: creates provider account if needed,
then POSTs to /api/v1/deployments with WXO provider_data shape
- Fix environment_variables payload: wrap values as { value, source: "raw" }
to satisfy EnvVarValueSpec schema
- Fix connection app_id: prefix with conn_ so WXO name validation passes
(names must start with a letter)
- Multi-select connections with checkbox UI; persist connections in context
so they survive back/forward navigation
- Update Deployment type to match API response shape; remove mock fields
(url, status, health, lastModifiedBy)
- Wire useGetDeployments to real API; load provider ID from useGetProviderAccounts
- Update deployments table to display real fields: name, type, attached_count,
provider name, updated_at
* refactor: extract DeploymentsContent component to eliminate nested ternary
* feat: add Test Deployment chat modal for deployed agents
Implements a chat interface to test deployments directly from the UI.
Wires up two entry points: the stepper modal "Test" button (inline
transition) and the deployments table play button (standalone dialog).
- Add usePostDeploymentExecution and useGetDeploymentExecution hooks
hitting POST/GET /api/v1/deployments/executions
- Build test-deployment-modal: ChatHeader, ChatMessages, ChatInput,
ChatMessageBubble with user/bot avatars, loading dots, tool traces
- useDeploymentChat hook: recursive setTimeout polling (max 30 × 1.5s),
thread_id persistence for multi-turn, watsonx response parsing
(response_type/type text, wxo_thread_id extraction)
- Stepper modal transitions inline to TestDeploymentContent on Test click
- Deployments table play button opens standalone TestDeploymentModal
* feat: add syntax highlighting to chat code blocks using SimplifiedCodeTabComponent
* feat: add action menu and icon-based type badge to deployments table
- Add dropdown action menu (Duplicate, Update, Delete) to each row
- Replace left-border type badge with icon-based badge (Bot for Agent, Plug for MCP)
* refactor: remove connected status from provider card in stepper
* feat: auto-detect flow env vars in deployment connection form
- Add POST /deployments/variables/detections backend endpoint that scans
flow version data for credential fields (load_from_db=True globals and
password=True fallbacks) and returns detected variable names
- Derive meaningful env var names from the model field's category when no
global variable is linked (e.g. OPENAI_API_KEY from category "OpenAI")
- Add DetectEnvVarsRequest/DetectedEnvVar/DetectEnvVarsResponse schemas
- Add usePostDetectDeploymentEnvVars frontend mutation hook
- Pre-populate Create Connection env var rows with detected keys/values
when attaching a flow version; global variable selections render as tags
via InputComponent with global variable picker support
* feat: add empty state and smart default tab for available connections
- Default to "Create Connection" tab when no connections exist
- Show empty state with icon, description, and shortcut link when
the Available Connections tab has no items
* feat: redesign review step with two-column layout and env vars section
Match new design reference with Deployment/Attached Flows columns
and a masked Configuration section showing env variable keys.
* feat: integrate delete deployment with loading state and fix test modal flow
- Add useDeleteDeployment hook (DELETE /deployments/{id}, refetches list)
- Show spinner + faded row while deletion is in progress; fix race where
deleteTarget was cleared before isPending resolved by using separate deletingId state
- Redesign StepDeployStatus with animated spinner, ping ring, and success checkmark
- After deploy, "Test" closes the stepper and opens the standalone TestDeploymentModal
(consistent UI, correct providerId, chat reset on close)
- Prevent closing the stepper modal while deployment is in progress
* refactor: address PR review concerns for deployment UI
- Split step-attach-flows.tsx (656→274 lines) into FlowListPanel,
VersionPanel, and ConnectionPanel sub-components
- Extract Watsonx parser utilities into watsonx-result-parsers.ts,
reducing use-deployment-chat.ts from 384 to 277 lines
- Surface detectEnvVars errors via setErrorData instead of silently
resetting state
- Add EnvVarEntry named type to types.ts, replacing inline shape
repeated across two files
- Move import json to module level in deployments.py (PEP 8)
- Fix URL construction in useGetDeploymentExecution to use axios
params option, consistent with other hooks
- Remove commented-out MOCK_CONNECTIONS dead code
- Add TODO comment to hardcoded "watsonx-orchestrate" provider key
* refactor: apply React best practices and remove mock data from deployment UI
- Fix barrel imports: import directly from source files in deployments-page,
step-provider, and step-attach-flows
- Replace useEffect auto-select with derived effectiveFlowId in step-attach-flows
- Fix async state init bug for environmentTab using useRef guard pattern
- Fix stale closure in handleAddEnvVar with functional setState
- Wrap useState initial value in lazy initializer for envVars
- Wrap all 8 handlers in useCallback; wrap panel components in memo()
- Remove mock-data.ts; move PROVIDERS constant inline to step-provider
- Drop MOCK_CONNECTIONS (was empty array) from context
- Simplify step-provider UI: remove provider selection radio group since
only one provider exists; show watsonx Orchestrate as a static display
* feat: add Deploy button to canvas toolbar
Adds a primary-colored Deploy button at the far right of the canvas toolbar.
Clicking it saves the flow, creates a version snapshot, and opens the
deployment stepper modal with the current flow and version pre-selected in
the Attach Flows step, including auto-detection of environment variable keys.
* chore: disable ENABLE_DEPLOYMENTS feature flag
* fix: forward LANGFLOW_FEATURE_WXO_DEPLOYMENTS env var to frontend
The feature flag was reading from import.meta.env but the variable
was never injected by Vite's define config, so the deployments
feature was always disabled regardless of the .env value.
* feat: implement providers tab with environment list
Replace the "coming soon" placeholder in the Providers sub-tab with a
real table showing existing provider accounts (name, URL, provider key,
created date) fetched from the API, including loading skeleton and
empty state.
* feat: add provider creation modal with tab-aware action button
Create AddProviderModal with name, API key, and URL fields matching
the deploy modal. The top-right button now switches between
"New Deployment" and "New Environment" based on the active sub-tab.
* feat: implement provider account deletion with confirmation modal
Add useDeleteProviderAccount hook, wire it through ProvidersContent
and ProvidersTable with loading/disabled row state on delete, and
reuse DeleteConfirmationModal for the confirmation flow.
* fix: correct provider_key to watsonx-orchestrate and add API key visibility toggle
Fix the provider_key value sent to the API. Add eye/eye-off toggle
to the API Key input in both the add provider modal and the deploy
stepper's provider step.
* feat: navigate to deployments tab and open test modal after canvas deploy
After a successful deployment from the canvas deploy button, clicking
"Test" navigates to the deployments tab and auto-opens the test modal.
Also adds eye toggle to the deploy stepper's API Key field.
* Add llm config to deployment creation workflow
* [autofix.ci] apply automated fixes
* feat: add useGetDeploymentLlms query hook
Add missing query hook for the GET /deployments/llms endpoint,
resolving the broken import in step-type.tsx.
* Create provider env inline of step
Ensures the list llm call has an authed provider account to use
* feat: add extensibility for wxo tools in deployments api (#12425)
* feat(deployments): surface tool_ids in WXO API for explicit tool control
- Fix bind to reuse existing WXO tools via attachment lookup instead of
always creating new ones
- Add tool_id-based operations (bind_tool, unbind_tool, remove_tool_by_id)
alongside existing flow_version_id operations
- Add PATCH /deployments/snapshots/{provider_snapshot_id} endpoint to
update snapshot content with a new flow version (blast-radius bounded
to Langflow-tracked tools only)
- Add update_snapshot method to WXO adapter
- Enrich flow version list with deployment info (tool_id, tool_name,
app_ids) via FlowVersionReadWithDeployments API schema
- Surface tool_id in WatsonxApiToolAppBinding responses; flow_version_id
becomes optional for tool-id-based operations
* Revert "feat: Add Langflow Assistant chat panel for component generation (#11636)"
This reverts commit 61fac94139.
* Add some comments
* Reapply "feat: Add Langflow Assistant chat panel for component generation (#11636)"
This reverts commit d7f08791f0.
* fix(deployments): review fixes for wxo tools extensibility PR
- Add best-effort compensating rollback to update_snapshot endpoint
- Add N+1 TODO for provider account batch-fetch in build_deployment_info_map
- Remove unpopulated app_ids field from FlowVersionDeploymentInfo
- Use elif chains in validate_operation_references for tool-id ops
- Strip provider_snapshot_id once at function entry
- Add note about WXO-only update_snapshot adapter method
- Replace call-count-based _MultiQueryFakeDb with table-name dispatch
- Fix SnapshotUpdateRequest docstring route path
* fix doc
---------
Co-authored-by: Jordan Frazier <jordan.frazier@datastax.com>
* feat: allow attaching flows without connections in deployment stepper
Connections are now optional when attaching flows in step 3. Users can
skip the connection panel and proceed with just a version selected.
The deploy payload iterates selectedVersionByFlow so flows without
connections are included with an empty app_ids array.
* feat: show attached connections per flow in deployment stepper
Display connection names under each flow in the flow list panel so
users can see what's attached at a glance. Refactor the review step
to show configuration scoped per flow instead of a flat list.
* remove unused enriched flow version data. to be replaced by a new endpoint in /deployments
* Allow nonraw env vars wxo (#12435)
Allow vars to be interpreted through global vars
* feat: add update existing deployment from canvas deploy button (#12440)
* feat: add update existing deployment from canvas deploy button
When clicking Deploy on the canvas, if the flow already has existing
deployments, a choice dialog is shown allowing the user to either
update an existing deployment or create a new one. Updating calls
PATCH /deployments/snapshots/{provider_snapshot_id} with the new
flow version.
Backend changes:
- New GET /deployments/flow-attachments/{flow_id} endpoint to discover
existing deployments for a flow
- New CRUD function list_attachments_for_flow_with_deployment_info
- New schemas FlowDeploymentAttachmentItem/FlowDeploymentAttachmentsResponse
- Fix update_snapshot to properly construct flow_definition with nested
data structure, name, description, and last_tested_version
- Fix rollback path to cache ORM values before session.rollback()
Frontend changes:
- New useGetFlowDeploymentAttachments and usePatchSnapshot hooks
- New DeployChoiceDialog component with radio selection
- Modified deploy button to check for existing deployments on click
* feat: add flow_ids filter to deployments endpoint and fix snapshot update
Add a `flow_ids` query parameter to GET /deployments so the frontend can
discover which deployments a flow is part of. The response includes a new
`matched_attachments` field with per-attachment `flow_version_id` and
`provider_snapshot_id`, replacing the non-existent flow-attachments endpoint.
Refactor PATCH /snapshots to accept BaseFlowArtifact (via the mapper's new
resolve_snapshot_update_artifact method) instead of a raw dict, fixing the
misleading "Deployment name must include at least one alphanumeric character"
error that occurred because flow_version.data lacks a name field.
Frontend: rewrite useGetFlowDeploymentAttachments to query the real
GET /deployments endpoint per provider account.
* feat: refactor deploy dialog with update-snapshot flow and phased UI
Restructure deploy-choice-dialog into modular phases (provider, review,
deployment, update) to support both new deployments and snapshot updates.
Add use-get-deployments-by-providers query replacing the removed
flow-attachments endpoint, extract provider-credentials-form component,
and add error/navigation helpers. Backend: validate flow_id as string
in WatsonX Orchestrate service before deployment.
* [autofix.ci] apply automated fixes
* ruff
---------
Co-authored-by: Hamza Rashid <hzarashid@gmail.com>
Co-authored-by: Hamza Rashid <74062092+HzaRashid@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
* feat: reorder model field and add scrollable dropdown in deploy wizard type step
Move Model select to appear after Agent Name (before Description) and
constrain the dropdown with max-height + scroll. A bouncing chevron
indicator fades in/out to signal more items below.
* feat(deployments): add paginated deployment flow-version listing (#12453)
* feat(deployments): add paginated deployment flow-version listing with snapshot sync
Add a new read path for listing flow versions attached to a deployment:
- introduces GET /deployments/{deployment_id}/flows with page/size pagination
- returns attachment-scoped metadata (flow_version id, flow_id, version_number, attached_at, provider_snapshot_id)
- keeps provider-owned fields under provider_data on each item
Implement snapshot-aware synchronization behavior for this list endpoint:
- loads deployment attachments from DB, extracts provider_snapshot_id values, and verifies them via adapter list_snapshots(snapshot_ids=...)
- removes stale attachment rows when snapshot IDs no longer exist on the provider
- falls back to DB-only response without enrichment on any sync failure (including partial failures after list_snapshots)
Refactor mapper boundaries to follow shape_* contract conventions:
- helper now passes through SnapshotListResult | None instead of pre-building enrichment maps
- route delegates full response shaping to mapper.shape_flow_version_list_result(...)
- base mapper performs direct DB->API mapping for flow-version list items
- WXO mapper overrides list shaping to enrich provider_data with connection_app_ids from snapshot binding.langflow.connections
Normalize WXO connection extraction into a shared read-path helper:
- add extract_langflow_connections_binding(...) in core/tools.py
- reuse it in verify_tools_by_ids and service config listing to avoid duplicated nested payload parsing
Add/adjust persistence and tests:
- add CRUD helpers for paginated attachment+flow_version join and attachment counting
- extend schema and route tests for new response models/endpoint behavior
- add mapper tests for base direct mapping and WXO-specific enrichment
- add sync tests for snapshot-id verification, stale-row cleanup, and fallback semantics
* fix(deployments): normalize wxo snapshot provider_data contracts
Validate and normalize snapshot connection payloads across mapper/service flows, and align flow-version item provider_data to use app_ids.
Add typed snapshot-item schema support in lfx and expand tests for missing/malformed snapshot provider_data behavior.
* add flow name
* return empty app_ids list instead of null
---------
Co-authored-by: Jordan Frazier <jordan.frazier@datastax.com>
* ref: remove resource name prefix (#12459)
* refactor: remove resource_name_prefix from all naming paths
Remove the resource_name_prefix concept entirely. Tool names now use the
exact flow name (or user-provided name in the future). Agent names use the
deployment name directly with no prefix.
BE:
- Delete resource_name_prefix.py
- Remove field + validators from API schemas (create + update)
- Remove field + validators from adapter schemas (create + update)
- Remove from mapper _build_provider_payload_body
- Remove resource_prefix from ProviderCreatePlan and ProviderUpdatePlan
- Remove tool_name_prefix from all tool creation functions
- Remove resolve_resource_name_prefix from utils
- Remove prefix constants from constants.py
- Remove prefixed_deployment_name from config.py
- Clean 5 test files (remove prefix-only tests, update payloads)
FE:
- Remove toResourceNamePrefix function from types.ts
- Remove resource_name_prefix from DeploymentCreateRequest type
- Remove usage from deployment-stepper-context.tsx payload builder
* [autofix.ci] apply automated fixes
* refactor: remove resource_name_prefix from all naming paths
Remove the resource_name_prefix concept entirely. Tool names now use the
exact flow name (or user-provided name in the future). Agent names use the
deployment name directly with no prefix.
BE:
- Delete resource_name_prefix.py
- Remove field + validators from API schemas (create + update)
- Remove field + validators from adapter schemas (create + update)
- Remove from mapper _build_provider_payload_body
- Remove resource_prefix from ProviderCreatePlan and ProviderUpdatePlan
- Remove tool_name_prefix from all tool creation functions
- Remove resolve_resource_name_prefix from utils
- Remove prefix constants from constants.py
- Remove prefixed_deployment_name from config.py
- Clean 5 test files (remove prefix-only tests, update payloads)
FE:
- Remove toResourceNamePrefix function from types.ts
- Remove resource_name_prefix from DeploymentCreateRequest type
- Remove usage from deployment-stepper-context.tsx payload builder
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
* feat: wxo custom tool naming (#12460)
feat: custom tool naming when attaching flows to deployments
Users can now name tools when attaching flows in the deployment stepper.
If left blank, the flow name is used as the tool name.
BE:
- Add optional tool_name field to WatsonxApiBindOperation
- Mapper overrides raw_name_by_flow_version_id with user-provided tool_name
- Apply custom name to both raw payloads and provider bind operations
FE:
- Add toolNameByFlow state + setToolNameByFlow to stepper context
- Include tool_name in bind operations when set (trimmed, omitted if empty)
- Tool Name input in version panel (shown when a version is selected)
- Review step shows tool name (custom or flow name) in config cards
with flow name + version underneath
* feat(deployments): derive connection ID from user-provided name
Replace random UUID generation with a sanitized version of the
connection name. Input is restricted to alphanumeric, underscore,
and space characters; spaces are converted to underscores in the ID.
* feat(deployments): prevent duplicate connection names
Disable the Create Connection button and show a validation error
when a connection with the same name (case-insensitive) already exists.
* refactor(deployments): extract page logic into custom hooks and self-contained tab components
Extract state management from the monolithic DeploymentsPage into focused
custom hooks (useDeleteWithConfirmation, useProviderFilter, useTestDeploymentModal)
and consolidate each tab's modals into its own content component, reducing the
page from 270 to 62 lines.
* fix(deployments): forward thread_id through WxO execution lifecycle
* fix(deployments): extract WxO tool call traces from actual step_history format
The parser expected tool_use/tool_result fields but WxO returns
type: "tool_calls" with tool_calls[] and type: "tool_response" —
traces were never being extracted. Rewrites extractToolTraces to
correlate calls with responses via tool_call_id, captures
agent_display_name, and makes each trace individually expandable.
* feat(deployments): refactor WXO deployment schemas and create response mapping (#12454)
* feat(deployments): add paginated deployment flow-version listing with snapshot sync
Add a new read path for listing flow versions attached to a deployment:
- introduces GET /deployments/{deployment_id}/flows with page/size pagination
- returns attachment-scoped metadata (flow_version id, flow_id, version_number, attached_at, provider_snapshot_id)
- keeps provider-owned fields under provider_data on each item
Implement snapshot-aware synchronization behavior for this list endpoint:
- loads deployment attachments from DB, extracts provider_snapshot_id values, and verifies them via adapter list_snapshots(snapshot_ids=...)
- removes stale attachment rows when snapshot IDs no longer exist on the provider
- falls back to DB-only response without enrichment on any sync failure (including partial failures after list_snapshots)
Refactor mapper boundaries to follow shape_* contract conventions:
- helper now passes through SnapshotListResult | None instead of pre-building enrichment maps
- route delegates full response shaping to mapper.shape_flow_version_list_result(...)
- base mapper performs direct DB->API mapping for flow-version list items
- WXO mapper overrides list shaping to enrich provider_data with connection_app_ids from snapshot binding.langflow.connections
Normalize WXO connection extraction into a shared read-path helper:
- add extract_langflow_connections_binding(...) in core/tools.py
- reuse it in verify_tools_by_ids and service config listing to avoid duplicated nested payload parsing
Add/adjust persistence and tests:
- add CRUD helpers for paginated attachment+flow_version join and attachment counting
- extend schema and route tests for new response models/endpoint behavior
- add mapper tests for base direct mapping and WXO-specific enrichment
- add sync tests for snapshot-id verification, stale-row cleanup, and fallback semantics
* fix(deployments): normalize wxo snapshot provider_data contracts
Validate and normalize snapshot connection payloads across mapper/service flows, and align flow-version item provider_data to use app_ids.
Add typed snapshot-item schema support in lfx and expand tests for missing/malformed snapshot provider_data behavior.
* add flow name
* return empty app_ids list instead of null
* feat(deployments): refactor WXO deployment schemas and create response mapping
Restructure the Watsonx Orchestrate deployment contracts to remove redundant connection declaration and make create responses explicit and typed.
- Remove `existing_app_ids` from API and adapter `connections` payload schemas.
- Replace shared API payload base with separate create/update models and focused validators.
- Keep validation strict (no legacy/backward-compat handling for removed fields).
- Derive provider-side `existing_app_ids` in plan builders from:
operation app_ids - connections.raw_payloads[*].app_id
- Stop passing `existing_app_ids` through mapper payload translation.
- Add explicit create response shaping via mapper (`shape_deployment_create_result`) and route integration.
- Introduce typed create provider_data structure with `created_app_ids` and `tool_app_bindings`.
- Document and enforce `source_ref` normalization semantics:
UUID refs map to `flow_version_id`; non-UUID refs map to `None`; empty refs error.
- Remove obsolete create-response helper path and align mapper interface signatures.
- Update unit tests and assertions to reflect the new contract and validation wording.
* improve api error logs (remove internal details and surface more informative message when an invalid field is provided
* feat(deployments): API contract updates, bug fixes, and observability improvements
BREAKING CHANGES (API):
- Move variable detection endpoint from POST /deployments/variables/detections
to POST /variables/detections (variables router)
- Rename `reference_ids` to `flow_version_ids` in DetectVarsRequest schema
- Remove `existing_app_ids` from WXO create/update connection payloads
- Remove `resource_name_prefix` from WXO create/update payloads
- Add optional `tool_name` field to WXO bind operations
Backend:
- Rename DetectEnvVarsRequest/Response to DetectVarsRequest/Response (internal)
- Fix tool name mismatch (`name_of_raw not found in tools.raw_payloads`) during
deployment updates by applying tool_name override and aligning
filtered_raw_payloads with name-updated artifacts in the WXO mapper
- Fix "Missing snapshot bindings for added flow versions on update" by filtering
out already-attached flow version IDs in the route handler before calling
resolve_added_snapshot_bindings_for_update (stateless, DB-backed filtering)
- Switch WXO adapter core modules (retry, update, shared, create, tools) from
stdlib logging to lfx.log.logger (structlog) for consistent log output
- Upgrade rollback log calls from info to warning level for visibility
- Add logger.exception() calls in handle_adapter_errors() for
DeploymentServiceError, NotImplementedError, and ValueError to surface
provider errors (e.g. validation failures) in backend logs with tracebacks
Frontend:
- Remove existingAppIds from deployment stepper context and create payload
- Move detect-env-vars hook from deployments/ to variables/ query directory
- Update step-attach-flows import path and payload field (flow_version_ids)
Tests:
- Add 15 unit tests for detect_env_vars endpoint and _derive_env_var_name helper
- Add 3 route-handler tests for already-attached flow version filtering on update
- Update WXO adapter tests: drop existing_app_ids, fix agent name assertions,
fix mock signatures, adapt logging test for structlog
* fix: harden detect_env_vars endpoint against abuse
- Add max_length=50 on flow_version_ids to prevent resource exhaustion
- Hide /detections from OpenAPI schema (consistent with other variable routes)
- Return unresolved_ids in response so callers know which version IDs were skipped
* feat: add update agent impl (#12475)
* Allow updating an agent
ONLY allows attaching and detaching Flows.
Does NOT allow attaching / detaching connections, or editing attached
tool names.
* [autofix.ci] apply automated fixes
* [autofix.ci] apply automated fixes (attempt 2/3)
* remove print
* add missing files
* mypy
* Add edit flow tests
* [autofix.ci] apply automated fixes
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
* refactor(deployments): trim top-level deployment API surface for WXO-first flow
- Remove unused deployment stub routes and schemas:
- drop POST /deployments/{deployment_id}/redeploy and /duplicate handlers
- remove DeploymentRedeployResponse and DeploymentDuplicateResponse usage
- remove corresponding route-handler unit tests
- Simplify deployment list response contract:
- remove deployment_type from DeploymentListResponse
- update deployments route and base/WXO mapper list shapers to stop passing deployment_type
- Make deployment request schemas API-owned and stricter:
- replace shared-kernel strict wrappers with DeploymentSpec / DeploymentSpecUpdate
- remove create/update top-level config and flow-version mutation fields
(flow_version_ids, add_flow_version_ids, remove_flow_version_ids, config)
- tighten update validation to only accept spec and/or provider_data
- Align mapper behavior with trimmed API contracts:
- base mapper create/update now maps spec explicitly to BaseDeploymentData/BaseDeploymentDataUpdate
- remove base create/update handling for top-level snapshot/config/flow-version passthrough
- make base util_create_flow_version_ids and util_flow_version_patch return empty results by default
- update WXO mapper to use explicit spec mapping and provider_data-only operation reconciliation
- remove WXO 422 guards tied to removed top-level config/flow-version request fields
- Remove obsolete provider_spec plumbing in adapter schema/utilities:
- drop ProviderSpecModel and T_DeploymentSpec usage from lfx deployment schema
- make BaseDeploymentData inherit directly from BaseModel
- remove unused build_agent_payload helper that depended on provider_spec from WXO utils
- Update tests to reflect the trimmed contracts and current behavior:
- remove schema tests for removed config/flow-version helper models
- update schema compatibility tests to reject provider_spec in API deployment spec
- update base mapper tests for new create-result shaping and provider payload validation expectations
- remove WXO mapper tests asserting rejected top-level config/flow-version inputs
- adjust WXO mapper naming assertions to current flow/tool naming behavior
- update sync/service/payload formalization tests for provider_data-driven operations and provider_spec removal
* feat: add list all conns impl for wxo create deploy workflow (#12476)
* feat(deployments): list all WXO tenant connections in create workflow
- Fix backend list_configs to handle SDK Pydantic model objects (not just dicts)
- Add useGetDeploymentConfigs hook to fetch tenant connections by provider
- Seed existing connections into the attach-flows step on mount
- Add search/filter input for the available connections list
- Raise configs endpoint page size cap to 10k for large tenants
* test(deployments): add list_configs tests for Pydantic model handling
Cover the fix where SDK ConnectionsClient.list() returns Pydantic
model objects instead of dicts: pure models, mixed dicts+models,
deduplication, and non-dict/non-model skip behavior.
* add todo
* [autofix.ci] apply automated fixes
* fix(tests): update imports for removed to_deployment_create_response helper
The helper was replaced by BaseDeploymentMapper.shape_deployment_create_result
in the schema refactor (#12454). Update both test files to use the new method.
* fix(tests): remove shape_deployment_create_result from passthrough test
Method signature changed from single-arg passthrough to (result, deployment_row)
in the schema refactor (#12454). It's now tested in test_deployment_description_and_type.py.
* tests
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
* refactor(deployments): normalize deployment metadata and list payload contracts
- Source `get_deployment` name/description/type/timestamps from DB deployment rows and stop injecting `resource_key` into provider payloads.
- Clarify schema docs that `resource_key` is provider-originated but Langflow-owned once persisted.
- Replace `DeploymentConfigListItem`/`DeploymentSnapshotListItem` response envelopes with paginated `provider_data` payloads in API schemas.
- Update base deployment mapper to serialize config/snapshot items directly into paginated `provider_data.configs` and `provider_data.snapshots`.
- Add WXO API payload models and validation slots for config-list and snapshot-list provider result metadata.
- Add WXO adapter payload result contracts (`WatsonxConfigListResultData`, `WatsonxSnapshotListResultData`) and register them in deployment payload schemas.
- Update WXO service list-config/list-snapshot flows to parse and emit normalized provider result metadata (tenant scope now `{}`, deployment scope includes `deployment_id` and optional `tool_ids`).
- Remove `provider_data` from `lfx` `ConfigListItem` to align with the new list response contract.
- Update mapper registration tests to assert config/snapshot payload slots are wired.
- Update route-handler tests for new provider-result expectations and add regression coverage that `resource_key` is not injected into `provider_data`.
- Rewrite deployment schema tests around provider-data-only config/snapshot list responses and field-surface checks.
- Update WXO service tests for tenant-scope provider-result normalization.
- Update `lfx` schema tests to remove obsolete config-item `provider_data` assertions.
* feat(deployments): add WXO mapper shaping for config-list and snapshot-list responses
- Add `shape_config_list_result` and `shape_snapshot_list_result` to WXO
deployment mapper with pagination, slot validation, and HTTP 500 on
malformed payloads.
- Add `WatsonxApiConfigListItem` and `WatsonxApiSnapshotListItem` strict
payload models with string normalization validators.
- Wire `configs` and `snapshots` item lists into the existing
`WatsonxApiConfigListProviderData` and `WatsonxApiSnapshotListProviderData`
provider-data envelopes.
- Add mapper unit tests for config-list slot validation, snapshot-list
connections extraction, and malformed-payload rejection.
- Remove obsolete `resource_name_prefix` and `connections` from
deployment-sync update test fixture.
* feat(deployments): add provider identity to responses, nest execution endpoints under deployments
Surface provider_id and provider_key on all deployment responses so
clients can identify which provider owns a deployment without a side
lookup. Nest execution endpoints under their parent deployment path
and drop redundant deployment_id / provider_id parameters that clients
previously had to supply.
Deployment responses:
- Add provider_id (UUID) and provider_key (str) to _DeploymentResponseBase,
propagating to DeploymentGetResponse, DeploymentCreateResponse,
DeploymentUpdateResponse, DeploymentStatusResponse, and DeploymentListItem.
- Add provider_key parameter to BaseDeploymentMapper.shape_deployment_create_result,
shape_deployment_update_result, and shape_deployment_list_items.
- Update WatsonxOrchestrateDeploymentMapper overrides to match.
- Update resolve_adapter_from_deployment to return (row, adapter, provider_key)
and resolve_adapter_mapper_from_deployment to return
(row, adapter, mapper, provider_key).
- All route handlers pass provider_key through to shape methods and
response constructors.
Execution endpoints:
- POST /executions → POST /deployments/{deployment_id}/executions.
deployment_id moves from the request body to the URL path.
- GET /executions/{execution_id}?deployment_id=...
→ GET /deployments/{deployment_id}/executions/{execution_id}.
deployment_id moves from a query param to the URL path.
- Remove deployment_id from ExecutionCreateRequest schema.
- Remove unused resolve_adapter_mapper_from_provider_id import.
Tests:
- Update all mapper tests to pass provider_key and assert provider_id /
provider_key on shaped responses.
- Update route handler mocks for new helper return tuple sizes.
- Remove deployment_id from ExecutionCreateRequest test constructions.
- Fix pre-existing broken test_deployments_response_mapping.py (was
importing removed to_deployment_create_response helper; now uses
BaseDeploymentMapper.shape_deployment_create_result).
* refactor(deployments): delegate conflict error messaging to provider mappers
Move provider-specific conflict formatting out of shared helpers by adding a base mapper hook and a Watsonx override, thread mapper-aware conflict formatting through adapter error handling, and add tests for mapper delegation and fallback behavior.
* refactor(deployments): rename provider-account API fields and make update identifiers immutable
- rename provider-account API fields for cleaner contracts:
- create/get: provider_tenant_id -> tenant_id
- create/get: provider_url -> url
- keep provider_key and provider_data unchanged
- enforce immutable provider-account identifiers on PATCH:
- remove tenant_id and url from DeploymentProviderAccountUpdateRequest
- remove update-path URL allowlist validation
- trigger update credential verification only when provider_data changes
- limit provider-account updates to name and provider_data
- simplify mapper update behavior:
- resolve_provider_tenant_id now uses tenant_id parameter naming
- remove no-op WXO resolve_provider_account_update override
- remove dead auth_utils/decrypt path in WXO update verification
- align frontend deployment-provider-account usage with new API fields:
- POST payload now sends url
- ProviderAccount/ProviderCredentials now use tenant_id/url
- deployment provider UI components now read/write url consistently
- update deployment mapper rules and backend tests for new contract
- verify with targeted backend tests: 245 passed
* fix(deployments): flatten API payloads, normalize execution routes, and trim null response fields
- flatten v1 deployment API request contracts by removing nested `spec` from create/update payloads
- create now uses top-level `name`, `description`, `type`
- update now uses top-level `name`, `description`, `provider_data`
- update backend handlers and mapper wiring to consume top-level deployment fields end-to-end
- fix execution route paths to avoid duplicated segment:
- `/api/v1/deployments/{deployment_id}/executions`
- `/api/v1/deployments/{deployment_id}/executions/{execution_id}`
- align frontend execution query hooks/chat flow with deployment-id path params and remove provider-id execution params
- add response null-trimming tweaks in deployment read/list endpoints:
- set `response_model_exclude_none=True` on deployment list route
- set `response_model_exclude_none=True` on deployment get route
- normalize empty/non-dict `provider_data` to `None` before shaping detail response
- tighten watsonx API mapper payload contract:
- type `connections.raw_payloads[*].environment_variables` as `dict[EnvVarKey, EnvVarValueSpec]`
- remove unused API-level `provider_config` field from update raw connection payload
- refresh backend/frontend tests and deployment endpoint docs to match current API shapes and routes
* feat(deployments): improve attach flow step UX in deploy modal (#12482)
- Add detach flow button in both create and edit modes
- Defer version attachment until connection step is completed (skip/attach)
- Replace radio indicators with clickable version items that auto-advance to connections
- Move tool name editing to review page with inline edit/confirm pattern
- Sort newly created connections to top of list and remove variable count display
- Add visual distinction (blue border/bg) for attached versions
- Split review page connections into "Existing" and "New" sections
* feat(deployments): add expandable rows to show attached flows (#12483)
Allow users to click the "Attached" count in the deployments table to
expand a row showing the flow names and versions. Flows are fetched
lazily via GET /deployments/{id}/flows only when the row is expanded.
* feat(deployments): replace Duplicate with Details modal (#12492)
feat(deployments): replace Duplicate action with Details modal
Replace the unused "Duplicate" action menu item with a "Details" option
that opens a read-only modal showing deployment info, attached flows,
and their connections. Data is fetched via useGetDeployment,
useGetDeploymentAttachments, and useGetDeploymentConfigs.
* feat(deployments): redesign list contracts and normalize WXO payloads
Rework deployment list and flow attachment contracts to support flow-filtered list responses and lazy flow-version lookup in the frontend deploy dialog. Normalize WXO provider payload shapes by flattening provider entries, renaming identifier fields, and tightening adapter/API validation.
- replace deployment list-item matched_attachments with conditional flow_version_ids (omitted when no flow filter is provided)
- add flow_ids support to GET /api/v1/deployments/{id}/flows and propagate it through deployment sync helpers and flow_version_deployment_attachment CRUD filters
- flatten load_from_provider deployment entries, rename provider resource_key to id, and validate WXO entries with explicit fields
- rename WXO provider_data snapshot_ids to tool_ids for provider list/config metadata payloads
- inline WXO provider list entry model_validate payload construction for cleaner mapper logic
- add deployment description max-length contract in adapter/API schemas and enforce it in deployment CRUD create/update paths
- update frontend deploy-choice dialog to a two-request flow (list deployments first, fetch /flows on selection) and align FE deployment types/query hook params
- refresh mapper/route/schema/sync/frontend tests and add dedicated CRUD tests for deployment description length validation
* Add wxo lfx req override
* feat(deployments): align WXO provider_data list and connection payload contracts
- make provider-only deployment list responses use provider_data.deployments and omit top-level deployments/pagination fields
- rename config/snapshot provider_data list keys to connections and tools, and move page/size/total into provider_data
- remove provider_data.deployment_id from config/snapshot list payload shaping
- rename API connection input fields from raw_payloads to key_value and environment_variables to credentials
- add explicit credential item model (key/value/source) with duplicate-key validation
- map API credentials list back to adapter environment_variables dict in mapper for adapter compatibility
- make shared pagination fields optional in deployment response schemas and update config-list provider_data description to "connections"
- set response_model_exclude_none on /configs and /snapshots list routes to suppress unused top-level null pagination fields
- apply formatter quote normalization in WXO config validation debug log
* feat(deployments): add connection type to config listings, enforce security_scheme, and trim leaked adapter fields
- Config list items now expose a `type` field derived from the provider's
`security_scheme`, enabling callers to distinguish connection types
(e.g. `key_value_creds`) without a separate lookup
- Requests that return provider_data without a valid `security_scheme`
now fail fast with HTTP 500 instead of silently omitting the type
- Config list responses no longer leak `tool_ids` or `deployment_id` —
these were adapter-internal fields with no frontend or API consumers
- Snapshot list responses no longer leak `deployment_id`
* feat(deployments): flatten WXO API operations into per-entity keyed fields
Replace the discriminated `operations` array with explicit per-entity
fields for better type safety and developer experience:
- Create: `add_flows` + `upsert_tools` (create-only item, no remove_app_ids)
- Update: `upsert_flows` + `upsert_tools` + `remove_flows` + `remove_tools`
Each item carries its own add/remove app-id deltas instead of relying on
op-tag dispatch. The mapper consumes the new shapes directly and produces
unchanged adapter-layer operations, eliminating all isinstance dispatch.
Also corrects FlowVersionPatch semantics: upsert_flows.remove_app_ids
now only unbinds connections without detaching the flow from the agent;
detachment is exclusively driven by remove_flows.
* feat(deployments): align watsonx create/update payloads and created-tools responses
- Add `resource_key` to deployment create/update API responses and mapper output.
- Replace API-facing `tool_app_bindings` with `created_tools` in watsonx mapper responses.
- Shape `created_tools` from created snapshot/tool refs only (not all referenced tools).
- Flatten request `provider_data.connections` from nested `connections.key_value` to a direct list.
- Add duplicate `connections.app_id` validation using Counter and update related validation messages.
- Document connection typing strategy: implicit key_value today, future type field with default.
- Add strict `flow_version_id` validation on `WatsonxApiCreatedTool`
(accept UUID or UUID string; reject other types/invalid UUIDs).
- Update mapper/unit tests for new response contract, flattened connections, and non-UUID rejection.
* feat(wxo): tool name handling, rename support, and ownership safety (#12502)
* feat(wxo): tool name handling, rename support, and ownership safety
- Fix update_snapshot to preserve existing wxO tool name instead of
deriving from flow name (prevents overwriting custom tool names)
- Add _validate_tool_name at API boundary with deferred validation so
user-provided tool_name overrides aren't blocked by invalid flow names
- Add rename_tool operation (API + provider + plan + apply) with safety
checks: tool must be on agent, must exist, must have binding.langflow
- Add verify_langflow_owned guard to all tool mutation paths (create,
update, rename) to prevent modifying non-Langflow-managed tools
- Add WXO_LFX_REQUIREMENT_OVERRIDE env var for lfx version pinning
- Pre-seed resolved_connections from existing agent tools during update
- Surface tool_name in /flows endpoint response from wxO snapshot data
- Pre-populate tool names and connections in edit mode stepper (FE)
- Emit rename_tool operations from FE when pre-existing tool name changes
- Sort attached flows to top of flow list in edit mode (FE)
- Fix FE sending unsupported existing_app_ids field in update payload
- Add 24 backend + 6 frontend tests covering ownership checks, rename
safety, name validation, plan building, env var override, and payloads
* [autofix.ci] apply automated fixes
* [autofix.ci] apply automated fixes (attempt 2/3)
* chore(wxo): trim verbose pre-seeding logs, add plan entry/exit logging
- Condense per-tool pre-seeding debug logs into a single summary line
- Add plan summary log at entry of apply_provider_create_plan_with_rollback
- Add plan summary log at entry of apply_provider_update_plan_with_rollback
- Add agent creation result log in create path
* fix(wxo): resolve mypy, ruff, and lint errors
- Fix dict[str, str] → dict[UUID, str] type annotation for
raw_name_by_flow_version_id in mapper create/update paths (mypy)
- Move logger assignment after imports in config.py (ruff E402)
- Remove unnecessary result variable in shared.py (ruff TRY300/RET504)
- Add raise-from chain in tools.py _resolve_lfx_requirement (ruff B904)
- Hoist class aliases to module level in tests (ruff N806)
* fix(wxo): resolve mypy, ruff, and lint errors
- Fix dict[str, str] → dict[UUID, str] type annotation for
raw_name_by_flow_version_id in mapper create/update paths (mypy)
- Use .items() for dict iteration instead of key-only loop (ruff PLC0206)
- Move logger assignment after imports in config.py (ruff E402)
- Remove unnecessary result variable in shared.py (ruff TRY300/RET504)
- Add raise-from chain in tools.py _resolve_lfx_requirement (ruff B904)
- Split long log format string in create.py (ruff E501)
- Hoist class aliases to module level in tests (ruff N806)
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
* fix(deployments): allow watsonx updates without llm
Make PATCH provider_data.llm optional in the Watsonx deployment flow while preserving create-time llm requirements.
- allow missing llm in the API update payload model
- only include llm in mapper-built provider payloads when explicitly provided
- remove adapter update-schema validation that required llm for update operations
- clarify validator docs for empty/no-op provider_data handling
- update mapper/service/schema tests to accept update payloads without llm
* fix(deployments): normalize wxo config list to connection_id/app_id and fix provider_accounts naming
Align the config-list contract end-to-end so that items are keyed by
connection_id + app_id (matching the upstream SDK model) instead of
opaque id/name pairs derived from dict introspection.
Backend:
- Reshape mapper/payloads to emit connection_id/app_id/type.
- Replace loose dict/model_dump parsing with strict ListConfigsResponse
type checks; add _build_config_list_item factory and
_normalize_optional_text with documented SDK quirk guards.
- Enrich deployment-scope configs with security_scheme type via
get_drafts_by_ids.
- Rename DeploymentProviderAccountListResponse.providers to
provider_accounts for consistency with the entity name.
- Remove leftover debug print/hardcoded requirements in core/tools.
Frontend:
- Migrate DeploymentConfigItem to connection_id/app_id/type and
unwrap provider_data.connections in the query hook.
- Access provider_accounts instead of providers on the list response.
- Add onBlur confirm for editable tool names; fix overflow/truncation
in the connection panel.
Tests:
- Use real ListConfigsResponse SDK model in service tests.
- Add scope-shape consistency, type preservation, dict-filtering, and
mapper contract tests.
* fix(deployments): remove stale-tool fallback in list_snapshots and log unresolved IDs
Replace the phantom-stub fallback that synthesized SnapshotItems for
agent-referenced tool IDs when get_drafts_by_ids returned no results.
Those tools were likely deleted on the provider, and returning stubs
with the ID as the name and empty connections masked stale references
and risked corrupting downstream attachment sync.
Now returns only snapshots that actually resolve from the provider and
logs a warning with the stale tool IDs for observability. Also handles
partial resolution (some tools found, some not).
Update existing test to provide real tool data and add coverage for
all-stale and partial-resolution scenarios.
* fix(deployments): fail fast on invalid wxo config entries and trust provider identifiers
Fail fast when wxO returns unexpected config-list entry types, and preserve provider-provided connection/app identifiers without local normalization or deduplication. Also narrow conflict-detail mapping for connection errors to real conflict messages and update tests to match the new behavior.
* refactor: move tenant_id from top-level into provider_data for provider accounts
tenant_id is a provider-specific concept (e.g., WXO tenant vs Azure AD
tenant vs AWS account_id) and does not have universal semantics across
deployment providers. Moving it into provider_data keeps the top-level
API surface limited to Langflow-universal fields (id, name, provider_key,
url, timestamps) and lets each provider define its own metadata shape.
API schema changes:
- DeploymentProviderAccountCreateRequest: remove top-level tenant_id
field; tenant_id now arrives inside provider_data alongside api_key
- DeploymentProviderAccountGetResponse: remove top-level tenant_id;
add provider_data field for non-sensitive provider metadata (e.g.
{"tenant_id": "..."} for WXO); credentials are excluded
- DeploymentProviderAccountUpdateRequest: unchanged (already uses
provider_data for credential rotation)
Base mapper (base.py):
- resolve_provider_tenant_id: signature changed from (provider_url,
tenant_id) to (provider_url, provider_data); delegates to new
resolve_provider_tenant_id_from_data() for extraction/validation
- shape_provider_account_response: now includes provider_data via new
shape_provider_account_provider_data() method that returns non-sensitive
metadata (tenant_id when present)
WXO mapper (watsonx_orchestrate/mapper.py):
- resolve_provider_tenant_id: updated signature; extracts tenant_id
from provider_data first, falls back to URL extraction
- _validate_provider_data: strips mapper-owned metadata keys (tenant_id)
before passing to WatsonxVerifyCredentialsPayload slot (extra=forbid)
- New _credential_provider_data() helper for metadata/credential separation
Route + helpers:
- deployments.py create_provider_account: passes payload.provider_data
(not payload.tenant_id) to resolve_provider_tenant_id
- helpers.py resolve_provider_tenant_id: updated parameter from
tenant_id to provider_data
Frontend:
- ProviderAccount type: replaced tenant_id with optional provider_data
RULES.md: updated credential flow and defense-in-depth sections to
reflect provider_data as the source for provider metadata.
Tests: updated schema, base mapper, WXO mapper, and route handler tests
to use provider_data for tenant_id. Added tests for tenant metadata
passthrough, credential field filtering, and top-level tenant_id
rejection. Fixed two pre-existing test stubs missing resource_key.
No DB model or migration changes -- provider_tenant_id column remains;
the mapper extracts it from provider_data and stores it as before.
* refactor: move url from top-level into provider_data for provider accounts
url is a provider-specific concept (e.g., WXO regional endpoint vs
future provider base URLs) and does not have universal validation
semantics at the API schema level. Moving it into provider_data keeps
the top-level API surface limited to Langflow-universal fields (id,
name, provider_key, timestamps) and lets each provider mapper own URL
validation, normalization, and allowlist enforcement.
API schema changes:
- DeploymentProviderAccountCreateRequest: remove top-level url field;
url now arrives inside provider_data alongside api_key and tenant_id
- DeploymentProviderAccountGetResponse: remove top-level url; url is
returned inside provider_data (e.g. {"url": "...", "tenant_id": "..."})
- Remove schema-level check_provider_url_allowed model validator;
URL allowlist enforcement moves to the mapper layer
Base mapper (base.py):
- New validate_provider_url method using field_name= kwarg on
validate_provider_url util (replaces synthetic ValidationInfo hack)
- New resolve_provider_account_create (abstract): provider mappers
must override to assemble the full DB model for create
- New _guard_provider_data_for_update: shared null-guard + immutability
check for url/tenant_id; used by both resolve_provider_account_update
and WxO resolve_verify_credentials_for_update (eliminates duplication)
- Rename resolve_credential_fields → resolve_credentials
- Rename shape_provider_account_response → resolve_provider_account_response
- Rename shape_provider_account_provider_data →
resolve_provider_account_provider_data; now always includes url
- DeploymentApiPayloads extended with provider_account_create/update slots
WxO mapper (watsonx_orchestrate/mapper.py):
- New _parse_create_provider_data: single parse + URL allowlist check
shared by resolve_verify_credentials, resolve_provider_account_create,
and validate_provider_url (eliminates triple-parse on create path)
- New resolve_provider_account_create: assembles DeploymentProviderAccount
model with parsed url, tenant_id, api_key from provider_data
- resolve_verify_credentials_for_update: delegates immutability guard
to _guard_provider_data_for_update instead of duplicating inline checks
WxO payloads (watsonx_orchestrate/payloads.py):
- New WatsonxApiProviderAccountCreate: url (required), tenant_id
(optional), api_key (required), extra=forbid
- New WatsonxApiProviderAccountUpdate: api_key only, extra=forbid
(url and tenant_id are immutable after create)
Routes (deployments.py):
- create_provider_account: delegates to resolve_provider_account_create +
create_provider_account_from_model (no more manual kwarg assembly)
- All response shaping goes through resolve_provider_account_response
on the mapper instance
Helpers (helpers.py):
- Remove dead resolve_provider_tenant_id and to_provider_account_response
Utils (utils.py):
- validate_provider_url accepts optional field_name kwarg for
non-Pydantic callers; removes validate_non_empty_string dependency
CRUD (crud.py):
- New create_provider_account_from_model accepting DeploymentProviderAccount;
shared _create_provider_account_internal implementation
Frontend:
- ProviderAccount type: remove top-level url; read from provider_data.url
via getProviderAccountUrl helper (no backward-compat shim)
- Create payload: url moved into provider_data in request type,
modal, and stepper context
Tests: updated schema, base mapper, WxO mapper, and route handler tests
for url-in-provider_data contract. Added immutability rejection tests
for url/tenant_id on update path. Added resolve_provider_account_create
coverage. Error expectations updated for slot-based validation (HTTPException
422 with structured messages).
No DB model or migration changes -- provider_url column remains; the
mapper extracts it from provider_data and stores it as before.
* post-merge cleanup
* refactor: tighten provider-account create and flow-version item contracts
Require tenant_id at create time for wxO provider accounts (fail-fast
via _resolve_required_provider_tenant_id), move tenant_id response
shaping to the wxO mapper override, enforce non-empty tool_name on
flow-version items, consolidate snapshot resolution into a single
_resolve_flow_version_item_data_by_snapshot_id method, and remove the
resolve_verify_credentials backward-compat alias and dead base
shape_deployment_flow_version_item_data hook.
* fix(deployments): align API payload contracts and deployment query semantics
- backend(api): remove redundant flow-id normalization helpers in deployment routes and rely on validated query params directly, while keeping mutual-exclusion and load_from_provider guards.
- backend(schema): switch flow_version_ids query typing/validation to UUID-based semantics to match flow_ids and simplify downstream filtering.
- backend(executions): preserve nullable provider execution fields by removing exclude_none in watsonx execution result shaping (service + mapper) for passthrough fidelity.
- backend(mapper): register watsonx config_item_data payload slot for explicit config-item payload validation.
- frontend(queries): move provider account url into provider_data, send flow_ids as repeatable array params, and use paramsSerializer(indexes=null) for correct repeated-key query serialization.
- frontend(types/ui): migrate deployment/provider consumers from provider_account_id/provider_url to provider_id/provider_data.url across deployments page, stepper, details modal, tables, and deploy-choice dialog.
- tests: update API query tests and deployments UI tests to match new provider payload shape, provider_id usage, and repeated flow_ids param behavior.
This aligns backend and frontend deployment contracts and keeps execution provider payloads faithful to provider responses.
* get rid of redundant strip
* harden validation for provider account responses
* refactor(deployments): clean up mapper validation, fix stale FE tests, and show env selector when empty
- backend(mapper): combine _parse_create_provider_data + _resolve_required_provider_tenant_id
into _validate_create_provider_data; verify-credentials path now skips unnecessary tenant
extraction by calling _parse_create_provider_data directly.
- backend(mapper): remove _guard_provider_data_for_update from base mapper; inline null check
in resolve_provider_account_update and resolve_verify_credentials_for_update; immutable field
rejection (url, tenant_id) now handled by WatsonxApiProviderAccountUpdate extra='forbid'.
- backend(schema): remove dead _validate_str_id_list (superseded by _validate_uuid_list).
- backend(mapper): add docstring clarifying plaintext api_key in resolve_provider_account_create.
- frontend(fix): show environment selector when providers > 1 regardless of deployment count,
fixing catch-22 where users couldn't switch accounts when default had no deployments.
- frontend(tests): fix stale use-post-deployment test payload (spec/operations/op:bind →
name/type/add_flows), fix providers-table and step-provider fixtures (url → provider_data.url),
update create-mode test descriptions.
- frontend(types): tighten DeploymentFlowVersionItem.tool_name to required string; update JSDoc.
- frontend(comments): replace adapter-internal 'bind/bindings' terminology with 'attach/assignments'.
* fix(mapper): tighten user_id to UUID and remove silent falsy fallback in execution results
- Narrow resolve_provider_account_create user_id from UUID | str to UUID
in base mapper and wxo override; callers always pass UUID.
- Remove `model_dump() or None` in shape_execution_create_result and
shape_execution_status_result; the models have required fields so
model_dump() never returns an empty dict — the fallback was dead code
that would silently mask a bug.
* refactor(wxo-mapper): unify payload-slot error messaging with operation context
- mapper: replace per-callsite missing/malformed detail strings in
`_parse_required_payload_slot` with a single `operation` parameter.
- mapper: introduce provider-aware, neutral 500 error messages that add context
without blaming wxO vs adapter implementation.
- mapper: add `_PROVIDER_LABEL` and use it to keep error wording consistent.
- mapper: enhance `_parse_api_payload_slot` messaging to include provider context
and field-specific 422 errors.
- mapper: update all payload-slot call sites (provider account response, create/update
results, llm list, execution create/status, config item data) to pass operation labels.
- tests: update watsonx mapper unit tests to match the new error message patterns.
* fix: harden WXO tenant ID extraction to require terminal path segment
The tenant ID in WXO instance URLs (/instances/{tenant_id}) must be the
last path segment. Previously the extractor accepted URLs with trailing
segments (e.g. /instances/id/agents), which could silently extract the
wrong tenant from a full API endpoint URL. Updated all test fixtures to
use realistic 36-char UUID tenant IDs matching the real WXO URL format.
* add comment
* docstrings for payload validators
* clean up some stuff
* refactor: improve mapper payload slot error handling and logging
- Log slot_name on all error paths for debugging without exposing it
in user-facing HTTP responses
- Remove unused `field` parameter from _parse_api_payload_slot (always
"provider_data")
- Default `operation` to "this operation" in _parse_required_payload_slot
so callers aren't forced to provide one
- Add docstrings clarifying inbound (422) vs outbound (500) roles and
the slot_name logging convention
- Rename _parse_create_provider_data to _parse_and_check_url
- Use model_dump() in resolve_credentials instead of manual field
extraction
* refactor: tighten deployment mapper base contracts
Remove dead tenant resolver methods from the base and WXO deployment mappers,
and make provider-specific create-path hooks in the base mapper fail fast via
NotImplementedError. Update mapper tests and deployment mapper rules docs to
match the new contract and current tenant extraction flow.
* docs correction
* tighten up filtering logic and improve docs
* fix playwright tests
---------
Co-authored-by: Viktor Avelino <viktor.avelino@gmail.com>
Co-authored-by: Viktor Avelino <64113566+viktoravelino@users.noreply.github.com>
Co-authored-by: Jordan Frazier <jordan.frazier@datastax.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jordan Frazier <122494242+jordanrfrazier@users.noreply.github.com>
This commit is contained in:
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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.")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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]] = {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
|
||||
@ -49,7 +49,9 @@ export default function ProviderPhaseContent({
|
||||
>
|
||||
<span className="text-sm font-medium">{provider.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{provider.url}
|
||||
{typeof provider.provider_data?.url === "string"
|
||||
? provider.provider_data.url
|
||||
: "—"}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@ -44,6 +44,7 @@ describe("useGetDeploymentAttachments", () => {
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith("/api/v1/deployments/dep-1/flows", {
|
||||
params: { size: 50 },
|
||||
paramsSerializer: { indexes: null },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<DeploymentFlowVersionListResponse> => {
|
||||
const params = {
|
||||
size: 50,
|
||||
...(flow_ids && flow_ids.length > 0 ? { flow_ids } : {}),
|
||||
};
|
||||
const { data } = await api.get<DeploymentFlowVersionListResponse>(
|
||||
`${getURL("DEPLOYMENTS")}/${deploymentId}/flows`,
|
||||
{ params: { size: 50, flow_ids } },
|
||||
{
|
||||
params,
|
||||
paramsSerializer: { indexes: null },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<DeploymentListResponse> => {
|
||||
const params = {
|
||||
provider_id,
|
||||
...(flow_ids && flow_ids.length > 0 ? { flow_ids } : {}),
|
||||
page,
|
||||
size,
|
||||
};
|
||||
const { data } = await api.get<DeploymentListResponse>(
|
||||
`${getURL("DEPLOYMENTS")}`,
|
||||
{ params: { provider_id, flow_ids, page, size } },
|
||||
{
|
||||
params,
|
||||
paramsSerializer: { indexes: null },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -67,6 +67,7 @@ import DeploymentDetailsModal from "../components/deployment-details-modal/deplo
|
||||
|
||||
const makeDeployment = (overrides: Partial<Deployment> = {}): 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> = {}): Deployment => ({
|
||||
provider_data: { llm: "granite-13b-chat" },
|
||||
resource_key: "rk-1",
|
||||
attached_count: 2,
|
||||
provider_account_id: "prov-1",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@ -172,6 +172,7 @@ import DeploymentStepperModal from "../components/deployment-stepper-modal";
|
||||
|
||||
const makeDeployment = (overrides: Partial<Deployment> = {}): 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> = {}): 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,
|
||||
|
||||
@ -25,6 +25,7 @@ import DeploymentsTable from "../components/deployments-table";
|
||||
|
||||
const makeDeployment = (overrides: Partial<Deployment> = {}): Deployment => ({
|
||||
id: "dep-1",
|
||||
provider_id: "prov-1",
|
||||
name: "My Agent",
|
||||
description: null,
|
||||
type: "agent",
|
||||
@ -34,7 +35,6 @@ const makeDeployment = (overrides: Partial<Deployment> = {}): 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();
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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" },
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 ?? ""] ?? "—")
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
|
||||
@ -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({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">
|
||||
{providerMap[deployment.provider_account_id ?? ""] ?? "—"}
|
||||
{providerMap[deployment.provider_id ?? ""] ?? "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@ -63,7 +63,9 @@ export default function ProvidersTable({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="max-w-[300px] truncate text-sm text-muted-foreground">
|
||||
{provider.url}
|
||||
{typeof provider.provider_data?.url === "string"
|
||||
? provider.provider_data.url
|
||||
: "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@ -86,7 +86,9 @@ function EnvironmentList({
|
||||
{environment.name}
|
||||
</span>
|
||||
<span className="text-sm leading-tight text-muted-foreground">
|
||||
{environment.url}
|
||||
{typeof environment.provider_data?.url === "string"
|
||||
? environment.provider_data.url
|
||||
: "—"}
|
||||
</span>
|
||||
</span>
|
||||
</RadioSelectItem>
|
||||
|
||||
@ -42,7 +42,7 @@ interface DeploymentStepperInitialState {
|
||||
initialLlm?: string;
|
||||
/** Pre-populated tool names from provider (edit mode). Key = flowId. */
|
||||
initialToolNameByFlow?: Map<string, string>;
|
||||
/** Pre-populated connection bindings from provider (edit mode). Key = flowId. */
|
||||
/** Pre-populated connection assignments from provider (edit mode). Key = flowId. */
|
||||
initialConnectionsByFlow?: Map<string, string[]>;
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -12,7 +12,7 @@ const makeDeployment = (overrides: Partial<Deployment> = {}): 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 }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -27,8 +27,7 @@ export interface ProviderAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
provider_key: string;
|
||||
url: string;
|
||||
provider_data?: Record<string, unknown>;
|
||||
provider_data?: Record<string, unknown> | 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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user