diff --git a/src/lfx/src/lfx/base/composio/composio_base.py b/src/lfx/src/lfx/base/composio/composio_base.py index 8734ebab98..6d8300b6be 100644 --- a/src/lfx/src/lfx/base/composio/composio_base.py +++ b/src/lfx/src/lfx/base/composio/composio_base.py @@ -135,7 +135,7 @@ class ComposioBaseComponent(Component): SecretStrInput( name="generic_api_key", display_name="API Key", - info="", + info="Enter API key on Composio page", show=False, value="", required=False, @@ -1048,24 +1048,14 @@ class ComposioBaseComponent(Component): return auth_config.id def _initiate_connection(self, app_name: str) -> tuple[str, str]: - """Initiate OAuth connection and return (redirect_url, connection_id).""" + """Initiate connection using link method and return (redirect_url, connection_id).""" try: composio = self._build_wrapper() - auth_configs = composio.auth_configs.list(toolkit_slug=app_name) - if len(auth_configs.items) == 0: - auth_config_id = self.create_new_auth_config(app_name) - else: - auth_config_id = None - for auth_config in auth_configs.items: - if auth_config.auth_scheme == "OAUTH2": - auth_config_id = auth_config.id + # Always create a new auth config (previous behavior) + auth_config_id = self.create_new_auth_config(app_name) - auth_config_id = auth_configs.items[0].id - - connection_request = composio.connected_accounts.initiate( - user_id=self.entity_id, auth_config_id=auth_config_id - ) + connection_request = composio.connected_accounts.link(user_id=self.entity_id, auth_config_id=auth_config_id) redirect_url = getattr(connection_request, "redirect_url", None) connection_id = getattr(connection_request, "id", None) @@ -1078,12 +1068,12 @@ class ComposioBaseComponent(Component): msg = "No connection ID received from Composio" raise ValueError(msg) - logger.info(f"OAuth connection initiated for {app_name}: {redirect_url} (ID: {connection_id})") + logger.info(f"Connection initiated for {app_name}: {redirect_url} (ID: {connection_id})") return redirect_url, connection_id # noqa: TRY300 except (ValueError, ConnectionError, TypeError, AttributeError) as e: logger.error(f"Error initiating connection for {app_name}: {e}") - msg = f"Failed to initiate OAuth connection: {e}" + msg = f"Failed to initiate connection: {e}" raise ValueError(msg) from e def _check_connection_status_by_id(self, connection_id: str) -> str | None: @@ -1400,20 +1390,14 @@ class ComposioBaseComponent(Component): desc = field.get("description") self._add_text_field(build_config, name, disp, desc, required=required, default_value=default_val) - # a) AuthConfigCreation fields (for custom OAuth2, etc.) + # Only process AuthConfigCreation fields (for custom OAuth2, etc.) + # Connection initiation fields are now handled on Composio page via link method creation = fields.get("auth_config_creation") or fields.get("authConfigCreation") or {} # Process required fields process_fields(creation.get("required", []), required=True) # Process optional fields (excluding those with defaults and bearer_token) process_fields(creation.get("optional", []), required=False) - # b) ConnectedAccountInitiation fields (for API_KEY, etc.) - initiation = fields.get("connected_account_initiation") or fields.get("connectedAccountInitiation") or {} - # Process required fields - process_fields(initiation.get("required", []), required=True) - # Process optional fields (excluding those with defaults) - process_fields(initiation.get("optional", []), required=False) - def _collect_all_auth_field_names(self, schema: dict[str, Any] | None) -> set[str]: names: set[str] = set() if not schema: @@ -1523,10 +1507,15 @@ class ComposioBaseComponent(Component): selected_mode = (build_config.get("auth_mode") or {}).get("value") managed = (schema or {}).get("composio_managed_auth_schemes") or [] # Don't render custom fields if "Composio_Managed" is selected - if selected_mode and selected_mode != "Composio_Managed": + # For API_KEY and other token modes, no fields are needed as they use link method + token_modes = ["API_KEY", "BEARER_TOKEN", "BASIC"] + if selected_mode and selected_mode not in ["Composio_Managed", *token_modes]: self._clear_auth_dynamic_fields(build_config) self._render_custom_auth_fields(build_config, schema or {}, selected_mode) # Already reordered in _render_custom_auth_fields + elif selected_mode in token_modes: + # Clear any existing auth fields for token-based modes + self._clear_auth_dynamic_fields(build_config) except (TypeError, ValueError, AttributeError): pass @@ -1673,6 +1662,9 @@ class ComposioBaseComponent(Component): if mode == "Composio_Managed": # Composio_Managed → no extra fields needed pass + elif mode in ["API_KEY", "BEARER_TOKEN", "BASIC"]: + # Token-based modes → no fields needed, user enters on Composio page via link + pass elif isinstance(managed, list) and mode in managed: # This is a specific managed auth scheme (e.g., OAUTH2) but user can still choose custom # So we should render custom fields for this mode @@ -1737,6 +1729,13 @@ class ComposioBaseComponent(Component): # Create new connection ONLY if we truly have no usable connection yet if existing_active is None: + # Check if we already have a redirect URL in progress + current_auth_link_value = build_config.get("auth_link", {}).get("value", "") + if current_auth_link_value and current_auth_link_value.startswith(("http://", "https://")): + # We already have a redirect URL, don't create a new one + logger.info(f"Redirect URL already exists for {toolkit_slug}, skipping new creation") + return self.update_input_types(build_config) + try: # Determine auth mode schema = self._get_toolkit_schema() @@ -1759,7 +1758,7 @@ class ComposioBaseComponent(Component): build_config["auth_link"]["auth_tooltip"] = "Select Auth Mode" return self.update_input_types(build_config) # Custom modes: create auth config and/or initiate with config - # Validate required fields before creating any auth config + # Only validate auth_config_creation fields for OAUTH2 required_missing = [] if mode == "OAUTH2": req_names_pre = self._get_schema_field_names( @@ -1773,30 +1772,6 @@ class ComposioBaseComponent(Component): val = build_config[fname].get("value") if val in (None, ""): required_missing.append(fname) - elif mode == "API_KEY": - req_names_pre = self._get_schema_field_names( - schema, - "API_KEY", - "connected_account_initiation", - "required", - ) - for fname in req_names_pre: - if fname in build_config: - val = build_config[fname].get("value") - if val in (None, ""): - required_missing.append(fname) - else: - req_names_pre = self._get_schema_field_names( - schema, - mode, - "connected_account_initiation", - "required", - ) - for fname in req_names_pre: - if fname in build_config: - val = build_config[fname].get("value") - if val in (None, ""): - required_missing.append(fname) if required_missing: # Surface errors on each missing field for fname in required_missing: @@ -1820,24 +1795,18 @@ class ComposioBaseComponent(Component): # If an auth_config was already created via the button, use it and include initiation fields stored_ac_id = (build_config.get("auth_link") or {}).get("auth_config_id") if stored_ac_id: - # Build val from schema-declared connected_account_initiation required + rendered fields - val_payload = {} - init_req = self._get_schema_field_names( - schema, - "OAUTH2", - "connected_account_initiation", - "required", - ) - candidate_names = set(self._auth_dynamic_fields) | init_req - for fname in candidate_names: - if fname in build_config: - v = build_config[fname].get("value") - if v not in (None, ""): - val_payload[fname] = v - redirect = composio.connected_accounts.initiate( + # Check if we already have a redirect URL to prevent duplicates + current_link_value = build_config.get("auth_link", {}).get("value", "") + if current_link_value and current_link_value.startswith(("http://", "https://")): + logger.info( + f"Redirect URL already exists for {toolkit_slug} OAUTH2, skipping new creation" + ) + return self.update_input_types(build_config) + + # Use link method - no need to collect connection initiation fields + redirect = composio.connected_accounts.link( user_id=self.entity_id, auth_config_id=stored_ac_id, - config={"auth_scheme": "OAUTH2", "val": val_payload} if val_payload else None, ) redirect_url = getattr(redirect, "redirect_url", None) connection_id = getattr(redirect, "id", None) @@ -1848,6 +1817,9 @@ class ComposioBaseComponent(Component): # Clear action blocker text on successful initiation build_config["action_button"]["helper_text"] = "" build_config["action_button"]["helper_text_metadata"] = {} + # Clear any auth fields + schema = self._get_toolkit_schema() + self._clear_auth_fields_from_schema(build_config, schema) return self.update_input_types(build_config) # Otherwise, create custom OAuth2 auth config using schema-declared required fields credentials = {} @@ -1868,6 +1840,14 @@ class ComposioBaseComponent(Component): else: missing.append(fname) # proceed even if missing optional; backend will validate + # Check if we already have a redirect URL to prevent duplicates + current_link_value = build_config.get("auth_link", {}).get("value", "") + if current_link_value and current_link_value.startswith(("http://", "https://")): + logger.info( + f"Redirect URL already exists for {toolkit_slug} OAUTH2, skipping new creation" + ) + return self.update_input_types(build_config) + ac = composio.auth_configs.create( toolkit=toolkit_slug, options={ @@ -1877,30 +1857,8 @@ class ComposioBaseComponent(Component): }, ) auth_config_id = getattr(ac, "id", None) - # If the schema declares initiation required fields, render them and defer initiation - init_req = self._get_schema_field_names( - schema, - "OAUTH2", - "connected_account_initiation", - "required", - ) - if init_req: - self._clear_auth_dynamic_fields(build_config) - for name in init_req: - self._add_text_field( - build_config, - name=name, - display_name=name.replace("_", " ").title(), - info="Provide connection parameter", - required=True, - ) - build_config.setdefault("auth_link", {}) - build_config["auth_link"]["auth_config_id"] = auth_config_id - build_config["auth_link"]["value"] = "connect" - build_config["auth_link"]["auth_tooltip"] = "Connect" - return self.update_input_types(build_config) - # Otherwise initiate immediately - redirect = composio.connected_accounts.initiate( + # Use link method directly - no need to check for connection initiation fields + redirect = composio.connected_accounts.link( user_id=self.entity_id, auth_config_id=auth_config_id, ) @@ -1917,141 +1875,64 @@ class ComposioBaseComponent(Component): build_config["action_button"]["helper_text_metadata"] = {} return self.update_input_types(build_config) if mode == "API_KEY": + # Check if we already have a redirect URL to prevent duplicates + current_link_value = build_config.get("auth_link", {}).get("value", "") + if current_link_value and current_link_value.startswith(("http://", "https://")): + logger.info( + f"Redirect URL already exists for {toolkit_slug} API_KEY, skipping new creation" + ) + return self.update_input_types(build_config) + ac = composio.auth_configs.create( toolkit=toolkit_slug, options={"type": "use_custom_auth", "auth_scheme": "API_KEY", "credentials": {}}, ) auth_config_id = getattr(ac, "id", None) - # Build initiation config.val from schema-declared required names and dynamic fields - val_payload = {} - missing = [] - # Collect required names from schema - req_names = self._get_schema_field_names( - schema, - "API_KEY", - "connected_account_initiation", - "required", - ) - # Merge rendered dynamic fields and schema-required names - candidate_names = set(self._auth_dynamic_fields) | req_names - for fname in candidate_names: - if fname in build_config: - val = build_config[fname].get("value") - if val not in (None, ""): - val_payload[fname] = val - else: - missing.append(fname) - initiation = composio.connected_accounts.initiate( + # Use link method - user will enter API key on Composio page + initiation = composio.connected_accounts.link( user_id=self.entity_id, auth_config_id=auth_config_id, - config={"auth_scheme": "API_KEY", "val": val_payload}, ) connection_id = getattr(initiation, "id", None) redirect_url = getattr(initiation, "redirect_url", None) - # Do not store connection_id on initiation; only when ACTIVE + # API_KEY now also returns redirect URL with new link method if redirect_url: build_config["auth_link"]["value"] = redirect_url build_config["auth_link"]["auth_tooltip"] = "Disconnect" - else: - # No redirect for API_KEY; mark as connected - build_config["auth_link"]["value"] = "validated" - build_config["auth_link"]["auth_tooltip"] = "Disconnect" - # In both cases, hide auth fields immediately after successful initiation + # Hide auth fields immediately after successful initiation schema = self._get_toolkit_schema() self._clear_auth_fields_from_schema(build_config, schema) build_config["action_button"]["helper_text"] = "" build_config["action_button"]["helper_text_metadata"] = {} - # Convert auth_mode to pill for connected state - if not redirect_url and mode: # API_KEY or similar direct connection - build_config["auth_link"]["connection_id"] = connection_id - build_config.setdefault("auth_mode", {}) - build_config["auth_mode"]["value"] = mode - build_config["auth_mode"]["options"] = [mode] - build_config["auth_mode"]["show"] = False - try: - pill = TabInput( - name="auth_mode", - display_name="Auth Mode", - options=[mode], - value=mode, - ).to_dict() - pill["show"] = True - build_config["auth_mode"] = pill - except (TypeError, ValueError, AttributeError): - build_config["auth_mode"] = { - "name": "auth_mode", - "display_name": "Auth Mode", - "type": "tab", - "options": [mode], - "value": mode, - "show": True, - } - return self.update_input_types(build_config) # Generic custom auth flow for any other mode (treat like API_KEY) + # Check if we already have a redirect URL to prevent duplicates + current_link_value = build_config.get("auth_link", {}).get("value", "") + if current_link_value and current_link_value.startswith(("http://", "https://")): + logger.info(f"Redirect URL already exists for {toolkit_slug} {mode}, skipping new creation") + return self.update_input_types(build_config) + ac = composio.auth_configs.create( toolkit=toolkit_slug, options={"type": "use_custom_auth", "auth_scheme": mode, "credentials": {}}, ) auth_config_id = getattr(ac, "id", None) - val_payload = {} - req_names = self._get_schema_field_names( - schema, - mode, - "connected_account_initiation", - "required", - ) - candidate_names = set(self._auth_dynamic_fields) | req_names - for fname in candidate_names: - if fname in build_config: - val = build_config[fname].get("value") - if val not in (None, ""): - val_payload[fname] = val - initiation = composio.connected_accounts.initiate( + # Use link method - user will enter required fields on Composio page + initiation = composio.connected_accounts.link( user_id=self.entity_id, auth_config_id=auth_config_id, - config={"auth_scheme": mode, "val": val_payload}, ) connection_id = getattr(initiation, "id", None) redirect_url = getattr(initiation, "redirect_url", None) - # Do not store connection_id on initiation; only when ACTIVE if redirect_url: build_config["auth_link"]["value"] = redirect_url build_config["auth_link"]["auth_tooltip"] = "Disconnect" - else: - build_config["auth_link"]["value"] = "validated" - build_config["auth_link"]["auth_tooltip"] = "Disconnect" - build_config["auth_link"]["connection_id"] = connection_id - - # Clear auth fields when connected - schema = self._get_toolkit_schema() - self._clear_auth_fields_from_schema(build_config, schema) - - # Convert auth_mode to pill for connected state - if mode: - build_config.setdefault("auth_mode", {}) - build_config["auth_mode"]["value"] = mode - build_config["auth_mode"]["options"] = [mode] - build_config["auth_mode"]["show"] = False - try: - pill = TabInput( - name="auth_mode", - display_name="Auth Mode", - options=[mode], - value=mode, - ).to_dict() - pill["show"] = True - build_config["auth_mode"] = pill - except (TypeError, ValueError, AttributeError): - build_config["auth_mode"] = { - "name": "auth_mode", - "display_name": "Auth Mode", - "type": "tab", - "options": [mode], - "value": mode, - "show": True, - } + # Clear auth fields + schema = self._get_toolkit_schema() + self._clear_auth_fields_from_schema(build_config, schema) + build_config["action_button"]["helper_text"] = "" + build_config["action_button"]["helper_text_metadata"] = {} return self.update_input_types(build_config) except (ValueError, ConnectionError, TypeError) as e: logger.error(f"Error creating connection: {e}") @@ -2185,7 +2066,12 @@ class ComposioBaseComponent(Component): schema = self._get_toolkit_schema() mode = (build_config.get("auth_mode") or {}).get("value") managed = (schema or {}).get("composio_managed_auth_schemes") or [] - if mode and mode != "Composio_Managed" and not getattr(self, "_auth_dynamic_fields", set()): + token_modes = ["API_KEY", "BEARER_TOKEN", "BASIC"] + if ( + mode + and mode not in ["Composio_Managed", *token_modes] + and not getattr(self, "_auth_dynamic_fields", set()) + ): self._render_custom_auth_fields(build_config, schema or {}, mode) # Already reordered in _render_custom_auth_fields except (TypeError, ValueError, AttributeError): @@ -2230,6 +2116,12 @@ class ComposioBaseComponent(Component): # Handle auth config button click if field_name == "create_auth_config" and field_value == "create": try: + # Check if we already have a redirect URL to prevent duplicates + current_link_value = build_config.get("auth_link", {}).get("value", "") + if current_link_value and current_link_value.startswith(("http://", "https://")): + logger.info("Redirect URL already exists, skipping new auth config creation") + return self.update_input_types(build_config) + composio = self._build_wrapper() toolkit_slug = self.app_name.lower() schema = self._get_toolkit_schema() or {} @@ -2250,29 +2142,8 @@ class ComposioBaseComponent(Component): auth_config_id = getattr(ac, "id", None) build_config.setdefault("auth_link", {}) if auth_config_id: - # Check if there are connection initiation required fields - initiation_required = self._get_schema_field_names( - schema, "OAUTH2", "connected_account_initiation", "required" - ) - if initiation_required: - # Populate those fields dynamically for the user to fill - self._clear_auth_dynamic_fields(build_config) - for name in initiation_required: - # Render as text inputs to collect connection fields - self._add_text_field( - build_config, - name=name, - display_name=name.replace("_", " ").title(), - info="Provide connection parameter", - required=True, - ) - # Store the new auth_config_id so pressing Connect will use it - build_config["auth_link"]["auth_config_id"] = auth_config_id - build_config["auth_link"]["value"] = "connect" - build_config["auth_link"]["auth_tooltip"] = "Connect" - return self.update_input_types(build_config) - # If no initiation fields required, initiate immediately - connection_request = composio.connected_accounts.initiate( + # Use link method directly - no need to check for connection initiation fields + connection_request = composio.connected_accounts.link( user_id=self.entity_id, auth_config_id=auth_config_id ) redirect_url = getattr(connection_request, "redirect_url", None)