lfx-nightly 0.1.12.dev5__py3-none-any.whl → 0.1.12.dev7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import copy
2
+ import json
2
3
  import re
3
4
  from typing import Any
4
5
 
@@ -6,53 +7,28 @@ from composio import Composio
6
7
  from composio_langchain import LangchainProvider
7
8
  from langchain_core.tools import Tool
8
9
 
10
+ from lfx.base.mcp.util import create_input_schema_from_json_schema
9
11
  from lfx.custom.custom_component.component import Component
10
- from lfx.inputs.inputs import AuthInput, FileInput, InputTypes, MessageTextInput, SecretStrInput, SortableListInput
12
+ from lfx.inputs.inputs import (
13
+ AuthInput,
14
+ DropdownInput,
15
+ FileInput,
16
+ InputTypes,
17
+ MessageTextInput,
18
+ MultilineInput,
19
+ SecretStrInput,
20
+ SortableListInput,
21
+ StrInput,
22
+ TabInput,
23
+ )
11
24
  from lfx.io import Output
12
25
  from lfx.io.schema import flatten_schema, schema_to_langflow_inputs
13
26
  from lfx.log.logger import logger
14
27
  from lfx.schema.data import Data
15
28
  from lfx.schema.dataframe import DataFrame
16
- from lfx.schema.json_schema import create_input_schema_from_json_schema
17
29
  from lfx.schema.message import Message
18
30
 
19
31
 
20
- def _patch_graph_clean_null_input_types() -> None:
21
- """Monkey-patch Graph._create_vertex to clean legacy templates."""
22
- try:
23
- from lfx.graph.graph.base import Graph
24
-
25
- if getattr(Graph, "_composio_patch_applied", False):
26
- return
27
-
28
- original_create_vertex = Graph._create_vertex
29
-
30
- def _create_vertex_with_cleanup(self, frontend_data):
31
- try:
32
- node_id: str | None = frontend_data.get("id") if isinstance(frontend_data, dict) else None
33
- if node_id and "Composio" in node_id:
34
- template = frontend_data.get("data", {}).get("node", {}).get("template", {})
35
- if isinstance(template, dict):
36
- for field_cfg in template.values():
37
- if isinstance(field_cfg, dict) and field_cfg.get("input_types") is None:
38
- field_cfg["input_types"] = []
39
- except (AttributeError, TypeError, KeyError) as e:
40
- logger.debug(f"Composio template cleanup encountered error: {e}")
41
-
42
- return original_create_vertex(self, frontend_data)
43
-
44
- Graph._create_vertex = _create_vertex_with_cleanup # type: ignore[method-assign]
45
- Graph._composio_patch_applied = True # type: ignore[attr-defined]
46
- logger.debug("Applied Composio template cleanup patch to Graph._create_vertex")
47
-
48
- except (AttributeError, TypeError) as e:
49
- logger.debug(f"Failed to apply Composio Graph patch: {e}")
50
-
51
-
52
- # Apply the patch at import time
53
- _patch_graph_clean_null_input_types()
54
-
55
-
56
32
  class ComposioBaseComponent(Component):
57
33
  """Base class for Composio components with common functionality."""
58
34
 
@@ -73,6 +49,17 @@ class ComposioBaseComponent(Component):
73
49
  real_time_refresh=True,
74
50
  value="COMPOSIO_API_KEY",
75
51
  ),
52
+ DropdownInput(
53
+ name="auth_mode",
54
+ display_name="Auth Mode",
55
+ options=[],
56
+ placeholder="Select auth mode",
57
+ toggle=True,
58
+ toggle_disable=True,
59
+ show=False,
60
+ real_time_refresh=True,
61
+ helper_text="Choose how to authenticate with the toolkit.",
62
+ ),
76
63
  AuthInput(
77
64
  name="auth_link",
78
65
  value="",
@@ -117,6 +104,10 @@ class ComposioBaseComponent(Component):
117
104
  self._key_to_display_map: dict[str, str] = {}
118
105
  self._sanitized_names: dict[str, str] = {}
119
106
  self._action_schemas: dict[str, Any] = {}
107
+ # Toolkit schema cache per instance
108
+ self._toolkit_schema: dict[str, Any] | None = None
109
+ # Track generated custom auth inputs to hide/show/reset
110
+ self._auth_dynamic_fields: set[str] = set()
120
111
 
121
112
  def as_message(self) -> Message:
122
113
  result = self.execute_action()
@@ -130,14 +121,11 @@ class ComposioBaseComponent(Component):
130
121
  if isinstance(result, dict):
131
122
  result = [result]
132
123
  # Build DataFrame and avoid exposing a 'data' attribute via column access,
133
- result_dataframe = DataFrame(result)
134
- if hasattr(result_dataframe, "columns"):
135
- try:
136
- if "data" in result_dataframe.columns:
137
- result_dataframe = result_dataframe.rename(columns={"data": "_data"})
138
- except (AttributeError, TypeError, ValueError, KeyError) as e:
139
- logger.debug(f"Failed to rename 'data' column: {e}")
140
- return result_dataframe
124
+ # which interferes with logging utilities that probe for '.data'.
125
+ df = DataFrame(result)
126
+ if "data" in df.columns:
127
+ df = df.rename(columns={"data": "_data"})
128
+ return df
141
129
 
142
130
  def as_data(self) -> Data:
143
131
  result = self.execute_action()
@@ -358,6 +346,13 @@ class ComposioBaseComponent(Component):
358
346
 
359
347
  # Check original schema properties for file_uploadable fields
360
348
  original_props = parameters_schema.get("properties", {})
349
+
350
+ # Determine top-level fields that should be treated as single JSON inputs
351
+ json_parent_fields = set()
352
+ for top_name, top_schema in original_props.items():
353
+ if isinstance(top_schema, dict) and top_schema.get("type") in {"object", "array"}:
354
+ json_parent_fields.add(top_name)
355
+
361
356
  for field_name, field_schema in original_props.items():
362
357
  if isinstance(field_schema, dict):
363
358
  clean_field_name = field_name.replace("[0]", "")
@@ -373,6 +368,10 @@ class ComposioBaseComponent(Component):
373
368
 
374
369
  for field in raw_action_fields:
375
370
  clean_field = field.replace("[0]", "")
371
+ # Skip subfields of JSON parents; we will expose the parent as a single field
372
+ top_prefix = clean_field.split(".")[0].split("[")[0]
373
+ if top_prefix in json_parent_fields and "." in clean_field:
374
+ continue
376
375
  # Check if this field is attachment-related
377
376
  if clean_field.lower().startswith("attachment."):
378
377
  attachment_related_found = True
@@ -381,8 +380,11 @@ class ComposioBaseComponent(Component):
381
380
  # Handle conflicting field names - rename user_id to avoid conflicts with entity_id
382
381
  if clean_field == "user_id":
383
382
  clean_field = f"{self.app_name}_user_id"
384
- elif clean_field == "status":
385
- clean_field = f"{self.app_name}_status"
383
+
384
+ # Handle reserved attribute name conflicts (e.g., 'status') by prefixing with app name
385
+ # This prevents clashes with component attributes like self.status
386
+ if clean_field in {"status"}:
387
+ clean_field = f"{self.app_name}_{clean_field}"
386
388
 
387
389
  action_fields.append(clean_field)
388
390
 
@@ -391,6 +393,11 @@ class ComposioBaseComponent(Component):
391
393
  action_fields.append("attachment")
392
394
  file_upload_fields.add("attachment") # Attachment fields are also file upload fields
393
395
 
396
+ # Ensure parents for object/array are present as fields (single JSON field)
397
+ for parent in json_parent_fields:
398
+ if parent not in action_fields:
399
+ action_fields.append(parent)
400
+
394
401
  # Track boolean parameters so we can coerce them later
395
402
  properties = flat_schema.get("properties", {})
396
403
  if properties:
@@ -530,17 +537,17 @@ class ComposioBaseComponent(Component):
530
537
  # Handle conflicting field names - rename user_id to avoid conflicts with entity_id
531
538
  if clean_field_name == "user_id":
532
539
  clean_field_name = f"{self.app_name}_user_id"
533
- # Update
540
+ # Update the field schema description to reflect the name change
534
541
  field_schema_copy = field_schema.copy()
535
542
  field_schema_copy["description"] = (
536
543
  f"User ID for {self.app_name.title()}: " + field_schema["description"]
537
544
  )
538
545
  elif clean_field_name == "status":
539
546
  clean_field_name = f"{self.app_name}_status"
540
- # Update
547
+ # Update the field schema description to reflect the name change
541
548
  field_schema_copy = field_schema.copy()
542
- field_schema_copy["description"] = (
543
- f"Status for {self.app_name.title()}: " + field_schema["description"]
549
+ field_schema_copy["description"] = f"Status for {self.app_name.title()}: " + field_schema.get(
550
+ "description", ""
544
551
  )
545
552
  else:
546
553
  # Use the original field schema for all other fields
@@ -593,8 +600,20 @@ class ComposioBaseComponent(Component):
593
600
  if attachment_related_fields: # If we consolidated attachment fields
594
601
  file_upload_fields = file_upload_fields | {"attachment"}
595
602
 
603
+ # Identify top-level JSON parents (object/array) to render as single CodeInput
604
+ top_props_for_json = set()
605
+ props_dict = parameters_schema.get("properties", {}) if isinstance(parameters_schema, dict) else {}
606
+ for top_name, top_schema in props_dict.items():
607
+ if isinstance(top_schema, dict) and top_schema.get("type") in {"object", "array"}:
608
+ top_props_for_json.add(top_name)
609
+
596
610
  for inp in result:
597
611
  if hasattr(inp, "name") and inp.name is not None:
612
+ # Skip flattened subfields of JSON parents; handle array prefixes (e.g., parent[0].x)
613
+ raw_prefix = inp.name.split(".")[0]
614
+ base_prefix = raw_prefix.replace("[0]", "")
615
+ if base_prefix in top_props_for_json and ("." in inp.name or "[" in inp.name):
616
+ continue
598
617
  # Check if this specific field is a file upload field
599
618
  if inp.name.lower() in file_upload_fields or inp.name.lower() == "attachment":
600
619
  # Replace with FileInput for file upload fields
@@ -655,6 +674,24 @@ class ComposioBaseComponent(Component):
655
674
  else:
656
675
  processed_inputs.append(inp)
657
676
 
677
+ # Add single CodeInput for each JSON parent field
678
+ props_dict = parameters_schema.get("properties", {}) if isinstance(parameters_schema, dict) else {}
679
+ for top_name in top_props_for_json:
680
+ # Avoid duplicates if already present
681
+ if any(getattr(i, "name", None) == top_name for i in processed_inputs):
682
+ continue
683
+ top_schema = props_dict.get(top_name, {})
684
+ processed_inputs.append(
685
+ MultilineInput(
686
+ name=top_name,
687
+ display_name=top_schema.get("title") or top_name.replace("_", " ").title(),
688
+ info=(
689
+ top_schema.get("description") or "Provide JSON for this parameter (object or array)."
690
+ ),
691
+ required=top_name in required_fields_set,
692
+ )
693
+ )
694
+
658
695
  return processed_inputs
659
696
  return result # noqa: TRY300
660
697
  except ValueError as e:
@@ -707,9 +744,7 @@ class ComposioBaseComponent(Component):
707
744
  if inp.name is not None:
708
745
  inp_dict = inp.to_dict() if hasattr(inp, "to_dict") else inp.__dict__.copy()
709
746
 
710
- # Ensure input_types is always a list
711
- if not isinstance(inp_dict.get("input_types"), list):
712
- inp_dict["input_types"] = []
747
+ # Do not mutate input_types here; keep original configuration
713
748
 
714
749
  inp_dict.setdefault("show", True) # visible once action selected
715
750
  # Preserve previously entered value if user already filled something
@@ -773,7 +808,7 @@ class ComposioBaseComponent(Component):
773
808
  logger.info(f"OAuth connection initiated for {app_name}: {redirect_url} (ID: {connection_id})")
774
809
  return redirect_url, connection_id # noqa: TRY300
775
810
 
776
- except Exception as e:
811
+ except (ValueError, ConnectionError, TypeError, AttributeError) as e:
777
812
  logger.error(f"Error initiating connection for {app_name}: {e}")
778
813
  msg = f"Failed to initiate OAuth connection: {e}"
779
814
  raise ValueError(msg) from e
@@ -813,6 +848,22 @@ class ComposioBaseComponent(Component):
813
848
  else:
814
849
  return None
815
850
 
851
+ def _get_connection_auth_info(self, connection_id: str) -> tuple[str | None, bool | None]:
852
+ """Return (auth_scheme, is_composio_managed) for a given connection id, if available."""
853
+ try:
854
+ composio = self._build_wrapper()
855
+ connection = composio.connected_accounts.get(nanoid=connection_id)
856
+ auth_config = getattr(connection, "auth_config", None)
857
+ if auth_config is None and hasattr(connection, "__dict__"):
858
+ auth_config = getattr(connection.__dict__, "auth_config", None)
859
+ scheme = getattr(auth_config, "auth_scheme", None) if auth_config else None
860
+ is_managed = getattr(auth_config, "is_composio_managed", None) if auth_config else None
861
+ except (AttributeError, ValueError, ConnectionError, TypeError) as e:
862
+ logger.debug(f"Could not retrieve auth info for connection {connection_id}: {e}")
863
+ return None, None
864
+ else:
865
+ return scheme, is_managed
866
+
816
867
  def _disconnect_specific_connection(self, connection_id: str) -> None:
817
868
  """Disconnect a specific Composio connection by ID."""
818
869
  try:
@@ -825,12 +876,227 @@ class ComposioBaseComponent(Component):
825
876
  msg = f"Failed to disconnect connection {connection_id}: {e}"
826
877
  raise ValueError(msg) from e
827
878
 
879
+ def _to_plain_dict(self, obj: Any) -> Any:
880
+ """Recursively convert SDK models/lists to plain Python dicts/lists for safe .get access."""
881
+ try:
882
+ if isinstance(obj, dict):
883
+ return {k: self._to_plain_dict(v) for k, v in obj.items()}
884
+ if isinstance(obj, (list, tuple, set)):
885
+ return [self._to_plain_dict(v) for v in obj]
886
+ if hasattr(obj, "model_dump"):
887
+ try:
888
+ return self._to_plain_dict(obj.model_dump())
889
+ except (TypeError, AttributeError, ValueError):
890
+ pass
891
+ if hasattr(obj, "__dict__") and not isinstance(obj, (str, bytes)):
892
+ try:
893
+ return self._to_plain_dict({k: v for k, v in obj.__dict__.items() if not k.startswith("_")})
894
+ except (TypeError, AttributeError, ValueError):
895
+ pass
896
+ except (TypeError, ValueError, AttributeError, RecursionError):
897
+ return obj
898
+ else:
899
+ return obj
900
+
901
+ def _get_toolkit_schema(self) -> dict[str, Any] | None:
902
+ """Fetch and cache toolkit schema for auth details (modes and fields)."""
903
+ if self._toolkit_schema is not None:
904
+ return self._toolkit_schema
905
+ try:
906
+ composio = self._build_wrapper()
907
+ # The SDK typically offers a retrieve by slug; if not present, try a few fallbacks
908
+ app_slug = getattr(self, "app_name", "").lower()
909
+ if not app_slug:
910
+ return None
911
+ try:
912
+ schema = composio.toolkits.retrieve(slug=app_slug)
913
+ except (AttributeError, ValueError, ConnectionError, TypeError):
914
+ schema = None
915
+ for method_name, kwargs in (
916
+ ("retrieve", {"toolkit_slug": app_slug}),
917
+ ("get", {"slug": app_slug}),
918
+ ("get", {"toolkit_slug": app_slug}),
919
+ ):
920
+ try:
921
+ method = getattr(composio.toolkits, method_name)
922
+ schema = method(**kwargs)
923
+ if schema:
924
+ break
925
+ except (AttributeError, ValueError, ConnectionError, TypeError):
926
+ continue
927
+ self._toolkit_schema = self._to_plain_dict(schema)
928
+ except (AttributeError, ValueError, ConnectionError, TypeError) as e:
929
+ logger.debug(f"Could not retrieve toolkit schema for {getattr(self, 'app_name', '')}: {e}")
930
+ return None
931
+ else:
932
+ return self._toolkit_schema
933
+
934
+ def _extract_auth_modes_from_schema(self, schema: dict[str, Any] | None) -> list[str]:
935
+ """Return available auth modes (e.g., OAUTH2, API_KEY) from toolkit schema."""
936
+ if not schema:
937
+ return []
938
+ modes: list[str] = []
939
+ # composio_managed_auth_schemes: list[str]
940
+ managed = schema.get("composio_managed_auth_schemes") or schema.get("composioManagedAuthSchemes") or []
941
+ if isinstance(managed, list):
942
+ modes.extend([m for m in managed if isinstance(m, str)])
943
+ # auth_config_details: list with entries containing mode
944
+ details = schema.get("auth_config_details") or schema.get("authConfigDetails") or []
945
+ for item in details:
946
+ mode = item.get("mode") or item.get("auth_method")
947
+ if isinstance(mode, str) and mode not in modes:
948
+ modes.append(mode)
949
+ return modes
950
+
951
+ def _render_auth_mode_dropdown(self, build_config: dict, modes: list[str]) -> None:
952
+ """Populate and show the auth_mode control; if only one mode, show as selected chip-style list."""
953
+ try:
954
+ build_config.setdefault("auth_mode", {})
955
+ auth_mode_cfg = build_config["auth_mode"]
956
+ # Prefer the connected scheme if known; otherwise use schema-provided modes as-is
957
+ stored_scheme = (build_config.get("auth_link") or {}).get("auth_scheme")
958
+ if isinstance(stored_scheme, str) and stored_scheme:
959
+ modes = [stored_scheme]
960
+
961
+ if len(modes) <= 1:
962
+ # Single mode → show a pill in the auth_mode slot (right after API Key)
963
+ selected = modes[0] if modes else ""
964
+ try:
965
+ pill = TabInput(
966
+ name="auth_mode",
967
+ display_name="Auth Mode",
968
+ options=[selected] if selected else [],
969
+ value=selected,
970
+ ).to_dict()
971
+ pill["show"] = True
972
+ build_config["auth_mode"] = pill
973
+ except (TypeError, ValueError, AttributeError):
974
+ build_config["auth_mode"] = {
975
+ "name": "auth_mode",
976
+ "display_name": "Auth Mode",
977
+ "type": "tab",
978
+ "options": [selected],
979
+ "value": selected,
980
+ "show": True,
981
+ }
982
+ else:
983
+ # Multiple modes → normal dropdown, hide the display chip if present
984
+ auth_mode_cfg["options"] = modes
985
+ auth_mode_cfg["show"] = True
986
+ if not auth_mode_cfg.get("value") and modes:
987
+ auth_mode_cfg["value"] = modes[0]
988
+ if "auth_mode_display" in build_config:
989
+ build_config["auth_mode_display"]["show"] = False
990
+ auth_mode_cfg["helper_text"] = "Choose how to authenticate with the toolkit."
991
+ except (TypeError, ValueError, AttributeError) as e:
992
+ logger.debug(f"Failed to render auth_mode dropdown: {e}")
993
+
994
+ def _clear_auth_dynamic_fields(self, build_config: dict) -> None:
995
+ for fname in list(self._auth_dynamic_fields):
996
+ if fname in build_config:
997
+ build_config.pop(fname, None)
998
+ self._auth_dynamic_fields.clear()
999
+
1000
+ def _add_text_field(
1001
+ self,
1002
+ build_config: dict,
1003
+ name: str,
1004
+ display_name: str,
1005
+ info: str | None,
1006
+ *,
1007
+ required: bool,
1008
+ default_value: str | None = None,
1009
+ ) -> None:
1010
+ """Add a simple text input (StrInput) for custom auth forms, prefilled with schema defaults when available."""
1011
+ field = StrInput(
1012
+ name=name,
1013
+ display_name=display_name or name.replace("_", " ").title(),
1014
+ info=info or "",
1015
+ required=required,
1016
+ real_time_refresh=True,
1017
+ show=True,
1018
+ ).to_dict()
1019
+ if default_value is not None and default_value != "":
1020
+ field["value"] = default_value
1021
+ build_config[name] = field
1022
+ self._auth_dynamic_fields.add(name)
1023
+
1024
+ def _render_custom_auth_fields(self, build_config: dict, schema: dict[str, Any], mode: str) -> None:
1025
+ """Render fields for custom auth based on schema auth_config_details sections."""
1026
+ details = schema.get("auth_config_details") or schema.get("authConfigDetails") or []
1027
+ selected = None
1028
+ for item in details:
1029
+ if (item.get("mode") or item.get("auth_method")) == mode:
1030
+ selected = item
1031
+ break
1032
+ if not selected:
1033
+ return
1034
+ fields = selected.get("fields") or {}
1035
+ # a) AuthConfigCreation required fields for OAUTH2 custom
1036
+ creation = fields.get("auth_config_creation") or fields.get("authConfigCreation") or {}
1037
+ for req in creation.get("required", []):
1038
+ name = req.get("name")
1039
+ if not name:
1040
+ continue
1041
+ disp = req.get("display_name") or req.get("displayName") or name
1042
+ desc = req.get("description")
1043
+ default_val = req.get("default")
1044
+ self._add_text_field(build_config, name, disp, desc, required=True, default_value=default_val)
1045
+ # Optional auth_config_creation fields intentionally not rendered
1046
+ # b) ConnectedAccountInitiation fields for API_KEY mode
1047
+ initiation = fields.get("connected_account_initiation") or fields.get("connectedAccountInitiation") or {}
1048
+ for req in initiation.get("required", []):
1049
+ name = req.get("name")
1050
+ if not name:
1051
+ continue
1052
+ disp = req.get("display_name") or req.get("displayName") or name
1053
+ desc = req.get("description")
1054
+ default_val = req.get("default")
1055
+ self._add_text_field(build_config, name, disp, desc, required=True, default_value=default_val)
1056
+ for opt in initiation.get("optional", []):
1057
+ name = opt.get("name")
1058
+ if not name:
1059
+ continue
1060
+ disp = opt.get("display_name") or opt.get("displayName") or name
1061
+ desc = opt.get("description")
1062
+ default_val = opt.get("default")
1063
+ self._add_text_field(build_config, name, disp, desc, required=False, default_value=default_val)
1064
+
1065
+ def _collect_all_auth_field_names(self, schema: dict[str, Any] | None) -> set[str]:
1066
+ names: set[str] = set()
1067
+ if not schema:
1068
+ return names
1069
+ details = schema.get("auth_config_details") or schema.get("authConfigDetails") or []
1070
+ for item in details:
1071
+ fields = (item.get("fields") or {}) if isinstance(item, dict) else {}
1072
+ for section_key in (
1073
+ "auth_config_creation",
1074
+ "authConfigCreation",
1075
+ "connected_account_initiation",
1076
+ "connectedAccountInitiation",
1077
+ ):
1078
+ section = fields.get(section_key) or {}
1079
+ for bucket in ("required", "optional"):
1080
+ for entry in section.get(bucket, []) or []:
1081
+ name = entry.get("name") if isinstance(entry, dict) else None
1082
+ if name:
1083
+ names.add(name)
1084
+ # Only use names discovered from the toolkit schema; do not add aliases
1085
+ return names
1086
+
1087
+ def _clear_auth_fields_from_schema(self, build_config: dict, schema: dict[str, Any] | None) -> None:
1088
+ all_names = self._collect_all_auth_field_names(schema)
1089
+ for name in list(all_names):
1090
+ if name in build_config and isinstance(build_config[name], dict):
1091
+ # Hide and reset instead of removing to ensure UI updates immediately
1092
+ build_config[name]["show"] = False
1093
+ build_config[name]["value"] = ""
1094
+ # Also clear any tracked dynamic fields
1095
+ self._clear_auth_dynamic_fields(build_config)
1096
+
828
1097
  def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None) -> dict:
829
1098
  """Update build config for auth and action selection."""
830
- # Clean any legacy None values that may still be present
831
- for _fconfig in build_config.values():
832
- if isinstance(_fconfig, dict) and _fconfig.get("input_types") is None:
833
- _fconfig["input_types"] = []
1099
+ # Avoid normalizing legacy input_types here; rely on upstream fixes
834
1100
 
835
1101
  # BULLETPROOF tool_mode checking - check all possible places where tool_mode could be stored
836
1102
  instance_tool_mode = getattr(self, "tool_mode", False) if hasattr(self, "tool_mode") else False
@@ -880,6 +1146,19 @@ class ComposioBaseComponent(Component):
880
1146
  logger.info(f"Populating actions data for {getattr(self, 'app_name', 'unknown')}...")
881
1147
  self._populate_actions_data()
882
1148
  logger.info(f"Actions populated: {len(self._actions_data)} actions found")
1149
+ # Also fetch toolkit schema to drive auth UI
1150
+ schema = self._get_toolkit_schema()
1151
+ modes = self._extract_auth_modes_from_schema(schema)
1152
+ self._render_auth_mode_dropdown(build_config, modes)
1153
+ # If a mode is selected (including auto-default), render custom fields when not managed
1154
+ try:
1155
+ selected_mode = (build_config.get("auth_mode") or {}).get("value")
1156
+ managed = (schema or {}).get("composio_managed_auth_schemes") or []
1157
+ if selected_mode and not (isinstance(managed, list) and selected_mode in managed):
1158
+ self._clear_auth_dynamic_fields(build_config)
1159
+ self._render_custom_auth_fields(build_config, schema or {}, selected_mode)
1160
+ except (TypeError, ValueError, AttributeError):
1161
+ pass
883
1162
 
884
1163
  # CRITICAL: Set action options if we have actions (either from fresh population or cache)
885
1164
  if self._actions_data:
@@ -888,6 +1167,10 @@ class ComposioBaseComponent(Component):
888
1167
  {"name": self.sanitize_action_name(action), "metadata": action} for action in self._actions_data
889
1168
  ]
890
1169
  logger.info(f"Action options set in build_config: {len(build_config['action_button']['options'])} options")
1170
+ # Always (re)populate auth_mode as well when actions are available
1171
+ schema = self._get_toolkit_schema()
1172
+ modes = self._extract_auth_modes_from_schema(schema)
1173
+ self._render_auth_mode_dropdown(build_config, modes)
891
1174
  else:
892
1175
  build_config["action_button"]["options"] = []
893
1176
  logger.warning("No actions found, setting empty options")
@@ -902,31 +1185,127 @@ class ComposioBaseComponent(Component):
902
1185
  logger.info(f"Cleared stored connection_id '{stored_connection_before}' due to API key change")
903
1186
  else:
904
1187
  logger.info("DEBUG: EARLY No stored connection_id to clear on API key change")
1188
+ # Also clear any stored scheme and reset auth mode UI when API key changes
1189
+ build_config.setdefault("auth_link", {})
1190
+ build_config["auth_link"].pop("auth_scheme", None)
1191
+ build_config.setdefault("auth_mode", {})
1192
+ build_config["auth_mode"].pop("value", None)
1193
+ build_config["auth_mode"]["show"] = True
1194
+ # If auth_mode is currently a TabInput pill, convert it back to dropdown
1195
+ if isinstance(build_config.get("auth_mode"), dict) and build_config["auth_mode"].get("type") == "tab":
1196
+ build_config["auth_mode"].pop("type", None)
1197
+ # Re-render dropdown options for the new API key context
1198
+ try:
1199
+ schema = self._get_toolkit_schema()
1200
+ modes = self._extract_auth_modes_from_schema(schema)
1201
+ # Rebuild as DropdownInput to ensure proper rendering
1202
+ dd = DropdownInput(
1203
+ name="auth_mode",
1204
+ display_name="Auth Mode",
1205
+ options=modes,
1206
+ placeholder="Select auth mode",
1207
+ toggle=True,
1208
+ toggle_disable=True,
1209
+ show=True,
1210
+ real_time_refresh=True,
1211
+ helper_text="Choose how to authenticate with the toolkit.",
1212
+ ).to_dict()
1213
+ build_config["auth_mode"] = dd
1214
+ except (TypeError, ValueError, AttributeError):
1215
+ pass
1216
+ # NEW: Clear any selected action and hide generated fields when API key is re-entered
1217
+ try:
1218
+ if "action_button" in build_config and isinstance(build_config["action_button"], dict):
1219
+ build_config["action_button"]["value"] = "disabled"
1220
+ self._hide_all_action_fields(build_config)
1221
+ except (TypeError, ValueError, AttributeError):
1222
+ pass
905
1223
 
906
1224
  # Handle disconnect operations when tool mode is enabled
907
1225
  if field_name == "auth_link" and field_value == "disconnect":
908
- try:
909
- # Get the specific connection ID that's currently being used
910
- stored_connection_id = build_config.get("auth_link", {}).get("connection_id")
911
- if stored_connection_id:
912
- self._disconnect_specific_connection(stored_connection_id)
913
- else:
914
- # No connection ID stored - nothing to disconnect
915
- logger.warning("No connection ID found to disconnect")
916
- build_config["auth_link"]["value"] = "connect"
917
- build_config["auth_link"]["auth_tooltip"] = "Connect"
1226
+ # Soft disconnect: do not delete remote account; only clear local state
1227
+ stored_connection_id = build_config.get("auth_link", {}).get("connection_id")
1228
+ if not stored_connection_id:
1229
+ logger.warning("No connection ID found to disconnect (soft)")
1230
+ build_config.setdefault("auth_link", {})
1231
+ build_config["auth_link"]["value"] = "connect"
1232
+ build_config["auth_link"]["auth_tooltip"] = "Connect"
1233
+ build_config["auth_link"].pop("connection_id", None)
1234
+ build_config["action_button"]["helper_text"] = "Please connect before selecting actions."
1235
+ build_config["action_button"]["helper_text_metadata"] = {"variant": "destructive"}
1236
+ return build_config
1237
+
1238
+ # Handle auth mode change -> render appropriate fields based on schema
1239
+ if field_name == "auth_mode":
1240
+ schema = self._get_toolkit_schema() or {}
1241
+ # Clear any previously rendered auth fields when switching modes
1242
+ self._clear_auth_fields_from_schema(build_config, schema)
1243
+ mode = field_value if isinstance(field_value, str) else (build_config.get("auth_mode", {}).get("value"))
1244
+ if not mode and isinstance(build_config.get("auth_mode"), dict):
1245
+ mode = build_config["auth_mode"].get("value")
1246
+ # Always show auth_link for any mode
1247
+ build_config.setdefault("auth_link", {})
1248
+ build_config["auth_link"]["show"] = False
1249
+ # Reset connection state when switching modes
1250
+ build_config["auth_link"].pop("connection_id", None)
1251
+ build_config["auth_link"].pop("auth_config_id", None)
1252
+ build_config["auth_link"]["value"] = "connect"
1253
+ build_config["auth_link"]["auth_tooltip"] = "Connect"
1254
+ # If an ACTIVE connection already exists, don't render any auth fields
1255
+ existing_active = self._find_active_connection_for_app(self.app_name)
1256
+ if existing_active:
1257
+ connection_id, _ = existing_active
1258
+ self._clear_auth_fields_from_schema(build_config, schema)
1259
+ build_config.setdefault("create_auth_config", {})
1260
+ build_config["create_auth_config"]["show"] = False
1261
+ build_config["auth_link"]["value"] = "validated"
1262
+ build_config["auth_link"]["auth_tooltip"] = "Disconnect"
1263
+ build_config["auth_link"]["connection_id"] = connection_id
1264
+ # Reflect the connected auth scheme in the UI
1265
+ scheme, is_managed = self._get_connection_auth_info(connection_id)
1266
+ if scheme:
1267
+ build_config.setdefault("auth_link", {})
1268
+ build_config["auth_link"]["auth_scheme"] = scheme
1269
+ build_config.setdefault("auth_mode", {})
1270
+ build_config["auth_mode"]["value"] = scheme
1271
+ build_config["auth_mode"]["options"] = [scheme]
1272
+ build_config["auth_mode"]["show"] = False
1273
+ try:
1274
+ pill = TabInput(
1275
+ name="auth_mode",
1276
+ display_name="Auth Mode",
1277
+ options=[scheme],
1278
+ value=scheme,
1279
+ ).to_dict()
1280
+ pill["show"] = True
1281
+ build_config["auth_mode"] = pill
1282
+ except (TypeError, ValueError, AttributeError):
1283
+ build_config["auth_mode"] = {
1284
+ "name": "auth_mode",
1285
+ "display_name": "Auth Mode",
1286
+ "type": "tab",
1287
+ "options": [scheme],
1288
+ "value": scheme,
1289
+ "show": True,
1290
+ }
1291
+ build_config["action_button"]["helper_text"] = ""
1292
+ build_config["action_button"]["helper_text_metadata"] = {}
918
1293
  return build_config
919
- except (ValueError, ConnectionError) as e:
920
- logger.error(f"Error disconnecting: {e}")
921
- build_config["auth_link"]["value"] = "error"
922
- build_config["auth_link"]["auth_tooltip"] = f"Disconnect failed: {e!s}"
923
- return build_config
924
- else:
925
- build_config["auth_link"]["value"] = "connect"
926
- build_config["auth_link"]["auth_tooltip"] = "Connect"
927
- build_config["auth_link"].pop("connection_id", None) # Clear stored connection ID
928
- build_config["action_button"]["helper_text"] = "Please connect before selecting actions."
929
- build_config["action_button"]["helper_text_metadata"] = {"variant": "destructive"}
1294
+ if mode:
1295
+ managed = schema.get("composio_managed_auth_schemes") or []
1296
+ # Always hide the Create Auth Config control (used internally only)
1297
+ build_config.setdefault("create_auth_config", {})
1298
+ build_config["create_auth_config"]["show"] = False
1299
+ build_config["create_auth_config"]["display_name"] = ""
1300
+ build_config["create_auth_config"]["value"] = ""
1301
+ build_config["create_auth_config"]["helper_text"] = ""
1302
+ build_config["create_auth_config"]["options"] = ["create"]
1303
+ if isinstance(managed, list) and mode in managed:
1304
+ # Managed no extra fields
1305
+ pass
1306
+ else:
1307
+ # Custom → render only required fields based on the toolkit schema
1308
+ self._render_custom_auth_fields(build_config, schema, mode)
930
1309
  return build_config
931
1310
 
932
1311
  # Handle connection initiation when tool mode is enabled
@@ -946,36 +1325,268 @@ class ComposioBaseComponent(Component):
946
1325
  logger.info(f"Using existing ACTIVE connection {connection_id} for {toolkit_slug}")
947
1326
  return build_config
948
1327
 
949
- # Check if we have a stored connection ID with INITIATED status
950
- stored_connection_id = build_config.get("auth_link", {}).get("connection_id")
951
- if stored_connection_id:
952
- # Check status of existing connection
953
- status = self._check_connection_status_by_id(stored_connection_id)
954
- if status == "INITIATED":
955
- # Get redirect URL from stored connection
956
- try:
957
- composio = self._build_wrapper()
958
- connection = composio.connected_accounts.get(nanoid=stored_connection_id)
959
- state = getattr(connection, "state", None)
960
- if state and hasattr(state, "val"):
961
- redirect_url = getattr(state.val, "redirect_url", None)
1328
+ # Only reuse ACTIVE connections; otherwise create a new connection
1329
+ stored_connection_id = None
1330
+
1331
+ # Create new connection ONLY if we truly have no usable connection yet
1332
+ if existing_active is None:
1333
+ try:
1334
+ # Determine auth mode
1335
+ schema = self._get_toolkit_schema()
1336
+ mode = None
1337
+ if isinstance(build_config.get("auth_mode"), dict):
1338
+ mode = build_config["auth_mode"].get("value")
1339
+ # If no managed default exists (400 Default auth config), require mode selection
1340
+ managed = (schema or {}).get("composio_managed_auth_schemes") or []
1341
+ if isinstance(managed, list) and "OAUTH2" in managed and (mode is None or mode == "OAUTH2"):
1342
+ redirect_url, connection_id = self._initiate_connection(toolkit_slug)
1343
+ build_config["auth_link"]["value"] = redirect_url
1344
+ logger.info(f"New OAuth URL created for {toolkit_slug}: {redirect_url}")
1345
+ return build_config
1346
+ if not mode:
1347
+ build_config["auth_link"]["value"] = "connect"
1348
+ build_config["auth_link"]["auth_tooltip"] = "Select Auth Mode"
1349
+ return build_config
1350
+ # Custom modes: create auth config and/or initiate with config
1351
+ # Validate required fields before creating any auth config
1352
+ required_missing = []
1353
+ if mode == "OAUTH2":
1354
+ req_names_pre = self._get_schema_field_names(
1355
+ schema,
1356
+ "OAUTH2",
1357
+ "auth_config_creation",
1358
+ "required",
1359
+ )
1360
+ for fname in req_names_pre:
1361
+ if fname in build_config:
1362
+ val = build_config[fname].get("value")
1363
+ if val in (None, ""):
1364
+ required_missing.append(fname)
1365
+ elif mode == "API_KEY":
1366
+ req_names_pre = self._get_schema_field_names(
1367
+ schema,
1368
+ "API_KEY",
1369
+ "connected_account_initiation",
1370
+ "required",
1371
+ )
1372
+ for fname in req_names_pre:
1373
+ if fname in build_config:
1374
+ val = build_config[fname].get("value")
1375
+ if val in (None, ""):
1376
+ required_missing.append(fname)
1377
+ else:
1378
+ req_names_pre = self._get_schema_field_names(
1379
+ schema,
1380
+ mode,
1381
+ "connected_account_initiation",
1382
+ "required",
1383
+ )
1384
+ for fname in req_names_pre:
1385
+ if fname in build_config:
1386
+ val = build_config[fname].get("value")
1387
+ if val in (None, ""):
1388
+ required_missing.append(fname)
1389
+ if required_missing:
1390
+ # Surface errors on each missing field
1391
+ for fname in required_missing:
1392
+ if fname in build_config and isinstance(build_config[fname], dict):
1393
+ build_config[fname]["helper_text"] = "This field is required"
1394
+ build_config[fname]["helper_text_metadata"] = {"variant": "destructive"}
1395
+ # Also reflect in info for guaranteed visibility
1396
+ existing_info = build_config[fname].get("info") or ""
1397
+ build_config[fname]["info"] = f"Required: {existing_info}".strip()
1398
+ build_config[fname]["show"] = True
1399
+ # Add a visible top-level hint near Auth Mode as well
1400
+ build_config.setdefault("auth_mode", {})
1401
+ missing_joined = ", ".join(required_missing)
1402
+ build_config["auth_mode"]["helper_text"] = f"Missing required: {missing_joined}"
1403
+ build_config["auth_mode"]["helper_text_metadata"] = {"variant": "destructive"}
1404
+ build_config["auth_link"]["value"] = "connect"
1405
+ build_config["auth_link"]["auth_tooltip"] = f"Missing: {missing_joined}"
1406
+ return build_config
1407
+ composio = self._build_wrapper()
1408
+ if mode == "OAUTH2":
1409
+ # If an auth_config was already created via the button, use it and include initiation fields
1410
+ stored_ac_id = (build_config.get("auth_link") or {}).get("auth_config_id")
1411
+ if stored_ac_id:
1412
+ # Build val from schema-declared connected_account_initiation required + rendered fields
1413
+ val_payload = {}
1414
+ init_req = self._get_schema_field_names(
1415
+ schema,
1416
+ "OAUTH2",
1417
+ "connected_account_initiation",
1418
+ "required",
1419
+ )
1420
+ candidate_names = set(self._auth_dynamic_fields) | init_req
1421
+ for fname in candidate_names:
1422
+ if fname in build_config:
1423
+ v = build_config[fname].get("value")
1424
+ if v not in (None, ""):
1425
+ val_payload[fname] = v
1426
+ redirect = composio.connected_accounts.initiate(
1427
+ user_id=self.entity_id,
1428
+ auth_config_id=stored_ac_id,
1429
+ config={"auth_scheme": "OAUTH2", "val": val_payload} if val_payload else None,
1430
+ )
1431
+ redirect_url = getattr(redirect, "redirect_url", None)
1432
+ connection_id = getattr(redirect, "id", None)
962
1433
  if redirect_url:
963
1434
  build_config["auth_link"]["value"] = redirect_url
964
- logger.info(f"Reusing existing OAuth URL for {toolkit_slug}: {redirect_url}")
965
- return build_config
966
- except (AttributeError, ValueError, ConnectionError) as e:
967
- logger.debug(f"Could not retrieve connection {stored_connection_id}: {e}")
968
- # Continue to create new connection below
969
-
970
- # Create new OAuth connection ONLY if we truly have no usable connection yet
971
- if existing_active is None and not (stored_connection_id and status in ("ACTIVE", "INITIATED")):
972
- try:
973
- redirect_url, connection_id = self._initiate_connection(toolkit_slug)
974
- build_config["auth_link"]["value"] = redirect_url
975
- build_config["auth_link"]["connection_id"] = connection_id # Store connection ID
976
- logger.info(f"New OAuth URL created for {toolkit_slug}: {redirect_url}")
977
- except (ValueError, ConnectionError) as e:
978
- logger.error(f"Error creating OAuth connection: {e}")
1435
+ if connection_id:
1436
+ build_config["auth_link"]["connection_id"] = connection_id
1437
+ # Clear action blocker text on successful initiation
1438
+ build_config["action_button"]["helper_text"] = ""
1439
+ build_config["action_button"]["helper_text_metadata"] = {}
1440
+ return build_config
1441
+ # Otherwise, create custom OAuth2 auth config using schema-declared required fields
1442
+ credentials = {}
1443
+ missing = []
1444
+ # Collect required names from schema
1445
+ req_names = self._get_schema_field_names(
1446
+ schema,
1447
+ "OAUTH2",
1448
+ "auth_config_creation",
1449
+ "required",
1450
+ )
1451
+ candidate_names = set(self._auth_dynamic_fields) | req_names
1452
+ for fname in candidate_names:
1453
+ if fname in build_config:
1454
+ val = build_config[fname].get("value")
1455
+ if val not in (None, ""):
1456
+ credentials[fname] = val
1457
+ else:
1458
+ missing.append(fname)
1459
+ # proceed even if missing optional; backend will validate
1460
+ ac = composio.auth_configs.create(
1461
+ toolkit=toolkit_slug,
1462
+ options={
1463
+ "type": "use_custom_auth",
1464
+ "auth_scheme": "OAUTH2",
1465
+ "credentials": credentials,
1466
+ },
1467
+ )
1468
+ auth_config_id = getattr(ac, "id", None)
1469
+ # If the schema declares initiation required fields, render them and defer initiation
1470
+ init_req = self._get_schema_field_names(
1471
+ schema,
1472
+ "OAUTH2",
1473
+ "connected_account_initiation",
1474
+ "required",
1475
+ )
1476
+ if init_req:
1477
+ self._clear_auth_dynamic_fields(build_config)
1478
+ for name in init_req:
1479
+ self._add_text_field(
1480
+ build_config,
1481
+ name=name,
1482
+ display_name=name.replace("_", " ").title(),
1483
+ info="Provide connection parameter",
1484
+ required=True,
1485
+ )
1486
+ build_config.setdefault("auth_link", {})
1487
+ build_config["auth_link"]["auth_config_id"] = auth_config_id
1488
+ build_config["auth_link"]["value"] = "connect"
1489
+ build_config["auth_link"]["auth_tooltip"] = "Connect"
1490
+ return build_config
1491
+ # Otherwise initiate immediately
1492
+ redirect = composio.connected_accounts.initiate(
1493
+ user_id=self.entity_id,
1494
+ auth_config_id=auth_config_id,
1495
+ )
1496
+ redirect_url = getattr(redirect, "redirect_url", None)
1497
+ connection_id = getattr(redirect, "id", None)
1498
+ if redirect_url:
1499
+ build_config["auth_link"]["value"] = redirect_url
1500
+ if connection_id:
1501
+ build_config["auth_link"]["connection_id"] = connection_id
1502
+ # Hide auth fields immediately after successful initiation
1503
+ schema = self._get_toolkit_schema()
1504
+ self._clear_auth_fields_from_schema(build_config, schema)
1505
+ build_config["action_button"]["helper_text"] = ""
1506
+ build_config["action_button"]["helper_text_metadata"] = {}
1507
+ return build_config
1508
+ if mode == "API_KEY":
1509
+ ac = composio.auth_configs.create(
1510
+ toolkit=toolkit_slug,
1511
+ options={"type": "use_custom_auth", "auth_scheme": "API_KEY", "credentials": {}},
1512
+ )
1513
+ auth_config_id = getattr(ac, "id", None)
1514
+ # Build initiation config.val from schema-declared required names and dynamic fields
1515
+ val_payload = {}
1516
+ missing = []
1517
+ # Collect required names from schema
1518
+ req_names = self._get_schema_field_names(
1519
+ schema,
1520
+ "API_KEY",
1521
+ "connected_account_initiation",
1522
+ "required",
1523
+ )
1524
+ # Merge rendered dynamic fields and schema-required names
1525
+ candidate_names = set(self._auth_dynamic_fields) | req_names
1526
+ for fname in candidate_names:
1527
+ if fname in build_config:
1528
+ val = build_config[fname].get("value")
1529
+ if val not in (None, ""):
1530
+ val_payload[fname] = val
1531
+ else:
1532
+ missing.append(fname)
1533
+ initiation = composio.connected_accounts.initiate(
1534
+ user_id=self.entity_id,
1535
+ auth_config_id=auth_config_id,
1536
+ config={"auth_scheme": "API_KEY", "val": val_payload},
1537
+ )
1538
+ connection_id = getattr(initiation, "id", None)
1539
+ redirect_url = getattr(initiation, "redirect_url", None)
1540
+ # Do not store connection_id on initiation; only when ACTIVE
1541
+ if redirect_url:
1542
+ build_config["auth_link"]["value"] = redirect_url
1543
+ build_config["auth_link"]["auth_tooltip"] = "Disconnect"
1544
+ else:
1545
+ # No redirect for API_KEY; mark as connected
1546
+ build_config["auth_link"]["value"] = "validated"
1547
+ build_config["auth_link"]["auth_tooltip"] = "Disconnect"
1548
+ # In both cases, hide auth fields immediately after successful initiation
1549
+ schema = self._get_toolkit_schema()
1550
+ self._clear_auth_fields_from_schema(build_config, schema)
1551
+ build_config["action_button"]["helper_text"] = ""
1552
+ build_config["action_button"]["helper_text_metadata"] = {}
1553
+ return build_config
1554
+ # Generic custom auth flow for any other mode (treat like API_KEY)
1555
+ ac = composio.auth_configs.create(
1556
+ toolkit=toolkit_slug,
1557
+ options={"type": "use_custom_auth", "auth_scheme": mode, "credentials": {}},
1558
+ )
1559
+ auth_config_id = getattr(ac, "id", None)
1560
+ val_payload = {}
1561
+ req_names = self._get_schema_field_names(
1562
+ schema,
1563
+ mode,
1564
+ "connected_account_initiation",
1565
+ "required",
1566
+ )
1567
+ candidate_names = set(self._auth_dynamic_fields) | req_names
1568
+ for fname in candidate_names:
1569
+ if fname in build_config:
1570
+ val = build_config[fname].get("value")
1571
+ if val not in (None, ""):
1572
+ val_payload[fname] = val
1573
+ initiation = composio.connected_accounts.initiate(
1574
+ user_id=self.entity_id,
1575
+ auth_config_id=auth_config_id,
1576
+ config={"auth_scheme": mode, "val": val_payload},
1577
+ )
1578
+ connection_id = getattr(initiation, "id", None)
1579
+ redirect_url = getattr(initiation, "redirect_url", None)
1580
+ # Do not store connection_id on initiation; only when ACTIVE
1581
+ if redirect_url:
1582
+ build_config["auth_link"]["value"] = redirect_url
1583
+ build_config["auth_link"]["auth_tooltip"] = "Disconnect"
1584
+ else:
1585
+ build_config["auth_link"]["value"] = "validated"
1586
+ build_config["auth_link"]["auth_tooltip"] = "Disconnect"
1587
+ return build_config
1588
+ except (ValueError, ConnectionError, TypeError) as e:
1589
+ logger.error(f"Error creating connection: {e}")
979
1590
  build_config["auth_link"]["value"] = "connect"
980
1591
  build_config["auth_link"]["auth_tooltip"] = f"Error: {e!s}"
981
1592
  else:
@@ -1017,6 +1628,41 @@ class ComposioBaseComponent(Component):
1017
1628
  # Show validated connection status
1018
1629
  build_config["auth_link"]["value"] = "validated"
1019
1630
  build_config["auth_link"]["auth_tooltip"] = "Disconnect"
1631
+ build_config["auth_link"]["show"] = False
1632
+ # Update auth mode UI to reflect connected scheme
1633
+ scheme, is_managed = self._get_connection_auth_info(active_connection_id)
1634
+ if scheme:
1635
+ build_config.setdefault("auth_link", {})
1636
+ build_config["auth_link"]["auth_scheme"] = scheme
1637
+ build_config.setdefault("auth_mode", {})
1638
+ build_config["auth_mode"]["value"] = scheme
1639
+ build_config["auth_mode"]["options"] = [scheme]
1640
+ build_config["auth_mode"]["show"] = False
1641
+ try:
1642
+ pill = TabInput(
1643
+ name="auth_mode",
1644
+ display_name="Auth Mode",
1645
+ options=[scheme],
1646
+ value=scheme,
1647
+ ).to_dict()
1648
+ pill["show"] = True
1649
+ build_config["auth_mode"] = pill
1650
+ except (TypeError, ValueError, AttributeError):
1651
+ build_config["auth_mode"] = {
1652
+ "name": "auth_mode",
1653
+ "display_name": "Auth Mode",
1654
+ "type": "tab",
1655
+ "options": [scheme],
1656
+ "value": scheme,
1657
+ "show": True,
1658
+ }
1659
+ build_config["action_button"]["helper_text"] = ""
1660
+ build_config["action_button"]["helper_text_metadata"] = {}
1661
+ # Clear any auth fields since we are already connected
1662
+ schema = self._get_toolkit_schema()
1663
+ self._clear_auth_fields_from_schema(build_config, schema)
1664
+ build_config.setdefault("create_auth_config", {})
1665
+ build_config["create_auth_config"]["show"] = False
1020
1666
  build_config["action_button"]["helper_text"] = ""
1021
1667
  build_config["action_button"]["helper_text_metadata"] = {}
1022
1668
  else:
@@ -1025,20 +1671,30 @@ class ComposioBaseComponent(Component):
1025
1671
  build_config["action_button"]["helper_text"] = "Please connect before selecting actions."
1026
1672
  build_config["action_button"]["helper_text_metadata"] = {"variant": "destructive"}
1027
1673
 
1028
- # CRITICAL: If tool_mode is enabled from ANY source, immediately hide action field and return
1674
+ # CRITICAL: If tool_mode is enabled from ANY source, hide action UI but keep auth flow available
1029
1675
  if current_tool_mode:
1030
1676
  build_config["action_button"]["show"] = False
1031
1677
 
1032
- # CRITICAL: Hide ALL action parameter fields when tool mode is enabled
1678
+ # Hide ALL action parameter fields when tool mode is enabled
1033
1679
  for field in self._all_fields:
1034
1680
  if field in build_config:
1035
1681
  build_config[field]["show"] = False
1036
1682
 
1037
1683
  # Also hide any other action-related fields that might be in build_config
1038
1684
  for field_name_in_config in build_config: # noqa: PLC0206
1039
- # Skip base fields like api_key, tool_mode, action, etc.
1685
+ # Skip base fields like api_key, tool_mode, action, etc., and dynamic auth fields
1040
1686
  if (
1041
- field_name_in_config not in ["api_key", "tool_mode", "action_button", "auth_link", "entity_id"]
1687
+ field_name_in_config
1688
+ not in [
1689
+ "api_key",
1690
+ "tool_mode",
1691
+ "action_button",
1692
+ "auth_link",
1693
+ "entity_id",
1694
+ "auth_mode",
1695
+ "auth_mode_pill",
1696
+ ]
1697
+ and field_name_in_config not in getattr(self, "_auth_dynamic_fields", set())
1042
1698
  and isinstance(build_config[field_name_in_config], dict)
1043
1699
  and "show" in build_config[field_name_in_config]
1044
1700
  ):
@@ -1049,8 +1705,23 @@ class ComposioBaseComponent(Component):
1049
1705
  build_config["tool_mode"] = {"value": True}
1050
1706
  elif isinstance(build_config["tool_mode"], dict):
1051
1707
  build_config["tool_mode"]["value"] = True
1052
- # Don't proceed with any other logic that might override this
1053
- return build_config
1708
+ # Keep auth UI available and render fields if needed
1709
+ build_config.setdefault("auth_link", {})
1710
+ build_config["auth_link"]["show"] = False
1711
+ build_config["auth_link"]["display_name"] = ""
1712
+ try:
1713
+ schema = self._get_toolkit_schema()
1714
+ mode = (build_config.get("auth_mode") or {}).get("value")
1715
+ managed = (schema or {}).get("composio_managed_auth_schemes") or []
1716
+ if (
1717
+ mode
1718
+ and not (isinstance(managed, list) and mode in managed)
1719
+ and not getattr(self, "_auth_dynamic_fields", set())
1720
+ ):
1721
+ self._render_custom_auth_fields(build_config, schema or {}, mode)
1722
+ except (TypeError, ValueError, AttributeError):
1723
+ pass
1724
+ # Do NOT return here; allow auth flow to run in Tool Mode
1054
1725
 
1055
1726
  if field_name == "tool_mode":
1056
1727
  if field_value is True:
@@ -1064,11 +1735,97 @@ class ComposioBaseComponent(Component):
1064
1735
  return build_config
1065
1736
 
1066
1737
  if field_name == "action_button":
1738
+ # If selection is cancelled/cleared, remove generated fields
1739
+ def _is_cleared(val: Any) -> bool:
1740
+ return (
1741
+ not val
1742
+ or (
1743
+ isinstance(val, list)
1744
+ and (len(val) == 0 or (len(val) > 0 and isinstance(val[0], dict) and not val[0].get("name")))
1745
+ )
1746
+ or (isinstance(val, str) and val in ("", "disabled", "placeholder"))
1747
+ )
1748
+
1749
+ if _is_cleared(field_value):
1750
+ self._hide_all_action_fields(build_config)
1751
+ return build_config
1752
+
1067
1753
  self._update_action_config(build_config, field_value)
1068
1754
  # Keep the existing show/hide behaviour
1069
1755
  self.show_hide_fields(build_config, field_value)
1070
1756
  return build_config
1071
1757
 
1758
+ # Handle auth config button click
1759
+ if field_name == "create_auth_config" and field_value == "create":
1760
+ try:
1761
+ composio = self._build_wrapper()
1762
+ toolkit_slug = self.app_name.lower()
1763
+ schema = self._get_toolkit_schema() or {}
1764
+ # Collect required fields from the current build_config
1765
+ credentials = {}
1766
+ req_names = self._get_schema_field_names(schema, "OAUTH2", "auth_config_creation", "required")
1767
+ candidate_names = set(self._auth_dynamic_fields) | req_names
1768
+ for fname in candidate_names:
1769
+ if fname in build_config:
1770
+ val = build_config[fname].get("value")
1771
+ if val not in (None, ""):
1772
+ credentials[fname] = val
1773
+ # Create a new auth config using the collected credentials
1774
+ ac = composio.auth_configs.create(
1775
+ toolkit=toolkit_slug,
1776
+ options={"type": "use_custom_auth", "auth_scheme": "OAUTH2", "credentials": credentials},
1777
+ )
1778
+ auth_config_id = getattr(ac, "id", None)
1779
+ build_config.setdefault("auth_link", {})
1780
+ if auth_config_id:
1781
+ # Check if there are connection initiation required fields
1782
+ initiation_required = self._get_schema_field_names(
1783
+ schema, "OAUTH2", "connected_account_initiation", "required"
1784
+ )
1785
+ if initiation_required:
1786
+ # Populate those fields dynamically for the user to fill
1787
+ self._clear_auth_dynamic_fields(build_config)
1788
+ for name in initiation_required:
1789
+ # Render as text inputs to collect connection fields
1790
+ self._add_text_field(
1791
+ build_config,
1792
+ name=name,
1793
+ display_name=name.replace("_", " ").title(),
1794
+ info="Provide connection parameter",
1795
+ required=True,
1796
+ )
1797
+ # Store the new auth_config_id so pressing Connect will use it
1798
+ build_config["auth_link"]["auth_config_id"] = auth_config_id
1799
+ build_config["auth_link"]["value"] = "connect"
1800
+ build_config["auth_link"]["auth_tooltip"] = "Connect"
1801
+ return build_config
1802
+ # If no initiation fields required, initiate immediately
1803
+ connection_request = composio.connected_accounts.initiate(
1804
+ user_id=self.entity_id, auth_config_id=auth_config_id
1805
+ )
1806
+ redirect_url = getattr(connection_request, "redirect_url", None)
1807
+ connection_id = getattr(connection_request, "id", None)
1808
+ if redirect_url and redirect_url.startswith(("http://", "https://")):
1809
+ build_config["auth_link"]["value"] = redirect_url
1810
+ build_config["auth_link"]["auth_tooltip"] = "Disconnect"
1811
+ build_config["auth_link"]["connection_id"] = connection_id
1812
+ build_config["action_button"]["helper_text"] = ""
1813
+ build_config["action_button"]["helper_text_metadata"] = {}
1814
+ logger.info(f"New OAuth URL created for {toolkit_slug}: {redirect_url}")
1815
+ else:
1816
+ logger.error(f"Failed to initiate connection with new auth config: {redirect_url}")
1817
+ build_config["auth_link"]["value"] = "error"
1818
+ build_config["auth_link"]["auth_tooltip"] = f"Error: {redirect_url}"
1819
+ else:
1820
+ logger.error(f"Failed to create new auth config for {toolkit_slug}")
1821
+ build_config["auth_link"]["value"] = "error"
1822
+ build_config["auth_link"]["auth_tooltip"] = "Create Auth Config failed"
1823
+ except (ValueError, ConnectionError, TypeError) as e:
1824
+ logger.error(f"Error creating new auth config: {e}")
1825
+ build_config["auth_link"]["value"] = "error"
1826
+ build_config["auth_link"]["auth_tooltip"] = f"Error: {e!s}"
1827
+ return build_config
1828
+
1072
1829
  # Handle API key removal
1073
1830
  if field_name == "api_key" and len(field_value) == 0:
1074
1831
  build_config["auth_link"]["value"] = ""
@@ -1076,7 +1833,34 @@ class ComposioBaseComponent(Component):
1076
1833
  build_config["action_button"]["options"] = []
1077
1834
  build_config["action_button"]["helper_text"] = "Please connect before selecting actions."
1078
1835
  build_config["action_button"]["helper_text_metadata"] = {"variant": "destructive"}
1836
+ build_config.setdefault("auth_link", {})
1079
1837
  build_config["auth_link"].pop("connection_id", None)
1838
+ build_config["auth_link"].pop("auth_scheme", None)
1839
+ # Restore auth_mode dropdown and hide pill
1840
+ try:
1841
+ dd = DropdownInput(
1842
+ name="auth_mode",
1843
+ display_name="Auth Mode",
1844
+ options=[],
1845
+ placeholder="Select auth mode",
1846
+ toggle=True,
1847
+ toggle_disable=True,
1848
+ show=True,
1849
+ real_time_refresh=True,
1850
+ helper_text="Choose how to authenticate with the toolkit.",
1851
+ ).to_dict()
1852
+ build_config["auth_mode"] = dd
1853
+ except (TypeError, ValueError, AttributeError):
1854
+ build_config.setdefault("auth_mode", {})
1855
+ build_config["auth_mode"]["show"] = True
1856
+ build_config["auth_mode"].pop("value", None)
1857
+ # NEW: Clear any selected action and hide generated fields when API key is cleared
1858
+ try:
1859
+ if "action_button" in build_config and isinstance(build_config["action_button"], dict):
1860
+ build_config["action_button"]["value"] = "disabled"
1861
+ self._hide_all_action_fields(build_config)
1862
+ except (TypeError, ValueError, AttributeError):
1863
+ pass
1080
1864
  return build_config
1081
1865
 
1082
1866
  # Only proceed with connection logic if we have an API key
@@ -1166,7 +1950,7 @@ class ComposioBaseComponent(Component):
1166
1950
  configured_tools.append(tool)
1167
1951
  return configured_tools
1168
1952
 
1169
- async def _get_tools(self) -> list[Tool]:
1953
+ def _get_tools(self) -> list[Tool]:
1170
1954
  """Get tools with cached results and optimized name sanitization."""
1171
1955
  composio = self._build_wrapper()
1172
1956
  self.set_default_tools()
@@ -1228,22 +2012,26 @@ class ComposioBaseComponent(Component):
1228
2012
  if value is None or value == "" or (isinstance(value, list) and len(value) == 0):
1229
2013
  continue
1230
2014
 
2015
+ # Determine schema for this field
2016
+ prop_schema = schema_properties.get(field, {})
2017
+
2018
+ # Parse JSON for object/array string inputs (applies to required and optional)
2019
+ if isinstance(value, str) and prop_schema.get("type") in {"array", "object"}:
2020
+ try:
2021
+ value = json.loads(value)
2022
+ except json.JSONDecodeError:
2023
+ # Fallback for simple arrays of primitives
2024
+ if prop_schema.get("type") == "array":
2025
+ value = [item.strip() for item in value.split(",") if item.strip() != ""]
2026
+
1231
2027
  # For optional fields, be more strict about including them
1232
2028
  # Only include if the user has explicitly provided a meaningful value
1233
2029
  if field not in required_fields:
1234
- # Get the default value from the schema
1235
- field_schema = schema_properties.get(field, {})
1236
- schema_default = field_schema.get("default")
1237
-
1238
- # Skip if the current value matches the schema default
2030
+ # Compare against schema default after normalization
2031
+ schema_default = prop_schema.get("default")
1239
2032
  if value == schema_default:
1240
2033
  continue
1241
2034
 
1242
- # Convert comma-separated to list for array parameters (heuristic)
1243
- prop_schema = schema_properties.get(field, {})
1244
- if prop_schema.get("type") == "array" and isinstance(value, str):
1245
- value = [item.strip() for item in value.split(",")]
1246
-
1247
2035
  if field in self._bool_variables:
1248
2036
  value = bool(value)
1249
2037
 
@@ -1251,8 +2039,6 @@ class ComposioBaseComponent(Component):
1251
2039
  final_field_name = field
1252
2040
  if field.endswith("_user_id") and field.startswith(self.app_name):
1253
2041
  final_field_name = "user_id"
1254
- elif field.endswith("_status") and field.startswith(self.app_name):
1255
- final_field_name = "status"
1256
2042
 
1257
2043
  arguments[final_field_name] = value
1258
2044
 
@@ -1289,3 +2075,83 @@ class ComposioBaseComponent(Component):
1289
2075
 
1290
2076
  def set_default_tools(self):
1291
2077
  """Set the default tools."""
2078
+
2079
+ def _get_schema_field_names(
2080
+ self,
2081
+ schema: dict[str, Any] | None,
2082
+ mode: str,
2083
+ section_kind: str,
2084
+ bucket: str,
2085
+ ) -> set[str]:
2086
+ names: set[str] = set()
2087
+ if not schema:
2088
+ return names
2089
+ details = schema.get("auth_config_details") or schema.get("authConfigDetails") or []
2090
+ for item in details:
2091
+ if (item.get("mode") or item.get("auth_method")) != mode:
2092
+ continue
2093
+ fields = item.get("fields") or {}
2094
+ section = (
2095
+ fields.get(section_kind)
2096
+ or fields.get(
2097
+ "authConfigCreation" if section_kind == "auth_config_creation" else "connectedAccountInitiation"
2098
+ )
2099
+ or {}
2100
+ )
2101
+ for entry in section.get(bucket, []) or []:
2102
+ name = entry.get("name") if isinstance(entry, dict) else None
2103
+ if name:
2104
+ names.add(name)
2105
+ return names
2106
+
2107
+ def _get_schema_required_entries(
2108
+ self,
2109
+ schema: dict[str, Any] | None,
2110
+ mode: str,
2111
+ section_kind: str,
2112
+ ) -> list[dict[str, Any]]:
2113
+ if not schema:
2114
+ return []
2115
+ details = schema.get("auth_config_details") or schema.get("authConfigDetails") or []
2116
+ for item in details:
2117
+ if (item.get("mode") or item.get("auth_method")) != mode:
2118
+ continue
2119
+ fields = item.get("fields") or {}
2120
+ section = (
2121
+ fields.get(section_kind)
2122
+ or fields.get(
2123
+ "authConfigCreation" if section_kind == "auth_config_creation" else "connectedAccountInitiation"
2124
+ )
2125
+ or {}
2126
+ )
2127
+ req = section.get("required", []) or []
2128
+ # Normalize dict-like entries
2129
+ return [entry for entry in req if isinstance(entry, dict)]
2130
+ return []
2131
+
2132
+ def _hide_all_action_fields(self, build_config: dict) -> None:
2133
+ """Hide and reset all action parameter inputs, regardless of trace flags."""
2134
+ # Hide known action fields
2135
+ for fname in list(self._all_fields):
2136
+ if fname in build_config and isinstance(build_config[fname], dict):
2137
+ build_config[fname]["show"] = False
2138
+ build_config[fname]["value"] = "" if fname not in self._bool_variables else False
2139
+ # Hide any other visible, non-protected fields that look like parameters
2140
+ protected = {
2141
+ "code",
2142
+ "entity_id",
2143
+ "api_key",
2144
+ "auth_link",
2145
+ "action_button",
2146
+ "tool_mode",
2147
+ "auth_mode",
2148
+ "auth_mode_pill",
2149
+ "create_auth_config",
2150
+ }
2151
+ for key, cfg in list(build_config.items()):
2152
+ if key in protected:
2153
+ continue
2154
+ if isinstance(cfg, dict) and "show" in cfg:
2155
+ cfg["show"] = False
2156
+ if "value" in cfg:
2157
+ cfg["value"] = ""