vibesurf 0.1.31__py3-none-any.whl → 0.1.33__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.
Files changed (35) hide show
  1. vibe_surf/_version.py +2 -2
  2. vibe_surf/agents/browser_use_agent.py +1 -1
  3. vibe_surf/agents/prompts/vibe_surf_prompt.py +6 -0
  4. vibe_surf/agents/report_writer_agent.py +50 -0
  5. vibe_surf/agents/vibe_surf_agent.py +56 -1
  6. vibe_surf/backend/api/composio.py +952 -0
  7. vibe_surf/backend/database/migrations/v005_add_composio_integration.sql +33 -0
  8. vibe_surf/backend/database/migrations/v006_add_credentials_table.sql +26 -0
  9. vibe_surf/backend/database/models.py +53 -1
  10. vibe_surf/backend/database/queries.py +312 -2
  11. vibe_surf/backend/main.py +28 -0
  12. vibe_surf/backend/shared_state.py +123 -9
  13. vibe_surf/chrome_extension/scripts/api-client.js +32 -0
  14. vibe_surf/chrome_extension/scripts/settings-manager.js +954 -1
  15. vibe_surf/chrome_extension/sidepanel.html +190 -0
  16. vibe_surf/chrome_extension/styles/settings-integrations.css +927 -0
  17. vibe_surf/chrome_extension/styles/settings-modal.css +7 -3
  18. vibe_surf/chrome_extension/styles/settings-responsive.css +37 -5
  19. vibe_surf/cli.py +98 -3
  20. vibe_surf/telemetry/__init__.py +60 -0
  21. vibe_surf/telemetry/service.py +112 -0
  22. vibe_surf/telemetry/views.py +156 -0
  23. vibe_surf/tools/browser_use_tools.py +90 -90
  24. vibe_surf/tools/composio_client.py +456 -0
  25. vibe_surf/tools/mcp_client.py +21 -2
  26. vibe_surf/tools/vibesurf_tools.py +290 -87
  27. vibe_surf/tools/views.py +16 -0
  28. vibe_surf/tools/website_api/youtube/client.py +35 -13
  29. vibe_surf/utils.py +13 -0
  30. {vibesurf-0.1.31.dist-info → vibesurf-0.1.33.dist-info}/METADATA +11 -9
  31. {vibesurf-0.1.31.dist-info → vibesurf-0.1.33.dist-info}/RECORD +35 -26
  32. {vibesurf-0.1.31.dist-info → vibesurf-0.1.33.dist-info}/WHEEL +0 -0
  33. {vibesurf-0.1.31.dist-info → vibesurf-0.1.33.dist-info}/entry_points.txt +0 -0
  34. {vibesurf-0.1.31.dist-info → vibesurf-0.1.33.dist-info}/licenses/LICENSE +0 -0
  35. {vibesurf-0.1.31.dist-info → vibesurf-0.1.33.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,952 @@
1
+ """
2
+ Composio API endpoints for VibeSurf Backend
3
+
4
+ Handles Composio integration management including toolkit configuration,
5
+ OAuth flow handling, and API key validation.
6
+ """
7
+ import pdb
8
+
9
+ from fastapi import APIRouter, HTTPException, Depends
10
+ from fastapi.responses import JSONResponse
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+ from typing import Dict, List, Optional, Any
13
+ import logging
14
+ import json
15
+ import asyncio
16
+ from datetime import datetime
17
+
18
+ from ..database.manager import get_db_session
19
+ from ..database.queries import ComposioToolkitQueries, LLMProfileQueries, CredentialQueries
20
+
21
+ router = APIRouter(prefix="/composio", tags=["composio"])
22
+
23
+ from vibe_surf.logger import get_logger
24
+
25
+ logger = get_logger(__name__)
26
+
27
+ # Pydantic models for Composio API
28
+ from pydantic import BaseModel, Field
29
+
30
+
31
+ class ComposioKeyVerifyRequest(BaseModel):
32
+ """Request model for verifying Composio API key"""
33
+ api_key: str = Field(description="Composio API key to verify")
34
+
35
+
36
+ class ComposioKeyVerifyResponse(BaseModel):
37
+ """Response model for Composio API key verification"""
38
+ valid: bool
39
+ message: str
40
+ user_info: Optional[Dict[str, Any]] = None
41
+
42
+
43
+ class ComposioToolkitResponse(BaseModel):
44
+ """Response model for Composio toolkit data"""
45
+ id: str
46
+ name: str
47
+ slug: str
48
+ description: Optional[str] = None
49
+ logo: Optional[str] = None
50
+ app_url: Optional[str] = None
51
+ enabled: bool
52
+ tools: Optional[List] = None
53
+ connection_status: Optional[str] = None
54
+ created_at: str
55
+ updated_at: str
56
+
57
+
58
+ class ComposioToolkitListResponse(BaseModel):
59
+ """Response model for toolkit list"""
60
+ toolkits: List[ComposioToolkitResponse]
61
+ total_count: int
62
+ synced_count: int
63
+
64
+
65
+ class ComposioToolkitToggleRequest(BaseModel):
66
+ """Request model for enabling/disabling a toolkit"""
67
+ enabled: bool = Field(description="Whether to enable or disable the toolkit")
68
+ force_reauth: Optional[bool] = Field(default=False, description="Force re-authentication if already connected")
69
+
70
+
71
+ class ComposioToolkitToggleResponse(BaseModel):
72
+ """Response model for toolkit toggle operation"""
73
+ success: bool
74
+ message: str
75
+ enabled: bool
76
+ requires_oauth: bool = False
77
+ oauth_url: Optional[str] = None
78
+ connected: bool = False
79
+ connection_status: str
80
+
81
+
82
+ class ComposioToolsResponse(BaseModel):
83
+ """Response model for toolkit tools"""
84
+ toolkit_slug: str
85
+ tools: List[Dict[str, Any]]
86
+ total_tools: int
87
+
88
+
89
+ class ComposioToolsUpdateRequest(BaseModel):
90
+ """Request model for updating selected tools"""
91
+ selected_tools: Dict[str, bool] = Field(description="Mapping of tool_name to enabled status")
92
+
93
+
94
+ class ComposioConnectionStatusResponse(BaseModel):
95
+ """Response model for connection status"""
96
+ toolkit_slug: str
97
+ connected: bool
98
+ connection_id: Optional[str] = None
99
+ status: str
100
+ last_checked: str
101
+
102
+
103
+ async def _get_composio_api_key_from_db() -> Optional[str]:
104
+ """Get Composio API key from database credentials table (encrypted)"""
105
+ try:
106
+ from .. import shared_state
107
+
108
+ if not shared_state.db_manager:
109
+ logger.warning("Database manager not available")
110
+ return None
111
+
112
+ async for db in shared_state.db_manager.get_session():
113
+ try:
114
+ api_key = await CredentialQueries.get_credential(db, "COMPOSIO_API_KEY")
115
+ return api_key
116
+ except Exception as e:
117
+ logger.error(f"Failed to retrieve Composio API key from database: {e}")
118
+ return None
119
+ except Exception as e:
120
+ logger.error(f"Database session error while retrieving Composio API key: {e}")
121
+ return None
122
+
123
+
124
+ async def _store_composio_api_key_in_db(api_key: str) -> bool:
125
+ """Store Composio API key in database credentials table (encrypted)"""
126
+ try:
127
+ from .. import shared_state
128
+ if not shared_state.db_manager:
129
+ logger.warning("Database manager not available")
130
+ return False
131
+
132
+ async for db in shared_state.db_manager.get_session():
133
+ try:
134
+ success = await CredentialQueries.store_credential(
135
+ db,
136
+ "COMPOSIO_API_KEY",
137
+ api_key,
138
+ "Composio API key for toolkit integrations"
139
+ )
140
+ if success:
141
+ await db.commit()
142
+ logger.info("✅ Composio API key stored successfully")
143
+ return success
144
+ except Exception as e:
145
+ logger.error(f"Failed to store Composio API key in database: {e}")
146
+ return False
147
+ except Exception as e:
148
+ logger.error(f"Database session error while storing Composio API key: {e}")
149
+ return False
150
+
151
+
152
+ async def _get_composio_instance():
153
+ """Get or create Composio instance from shared state"""
154
+ try:
155
+ from .. import shared_state
156
+ if shared_state.composio_instance is None:
157
+ # Try to get API key from database first
158
+ api_key = await _get_composio_api_key_from_db()
159
+ if not api_key:
160
+ # If no API key in database, Composio instance cannot be created
161
+ return None
162
+
163
+ # Import Composio here to avoid circular imports
164
+ from composio import Composio
165
+ from composio_langchain import LangchainProvider
166
+
167
+ # Create Composio instance
168
+ shared_state.composio_instance = Composio(
169
+ api_key=api_key,
170
+ provider=LangchainProvider()
171
+ )
172
+ logger.info("✅ Composio instance created successfully")
173
+
174
+ return shared_state.composio_instance
175
+ except Exception as e:
176
+ logger.error(f"Failed to get Composio instance: {e}")
177
+ return None
178
+
179
+
180
+ @router.get("/status")
181
+ async def get_composio_status(
182
+ db: AsyncSession = Depends(get_db_session)
183
+ ):
184
+ """
185
+ Get current Composio connection status without API validation
186
+ """
187
+ try:
188
+ from .. import shared_state
189
+ logger.info("Checking Composio connection status")
190
+
191
+ # Check if we already have a valid Composio instance
192
+ if shared_state.composio_instance is not None:
193
+ # try:
194
+ # # Quick test to verify instance is still valid
195
+ # await asyncio.to_thread(lambda: shared_state.composio_instance.toolkits.get())
196
+ # return {
197
+ # "connected": True,
198
+ # "key_valid": True,
199
+ # "has_key": True,
200
+ # "message": "Composio is connected and ready",
201
+ # "instance_available": True
202
+ # }
203
+ # except Exception as e:
204
+ # logger.warning(f"Composio instance validation failed: {e}")
205
+ # # Instance is invalid, clear it
206
+ # shared_state.composio_instance = None
207
+
208
+ return {
209
+ "connected": True,
210
+ "key_valid": True,
211
+ "has_key": True,
212
+ "message": "Composio is connected and ready",
213
+ "instance_available": True
214
+ }
215
+
216
+ # No valid instance, check if we have API key in database
217
+ api_key = await _get_composio_api_key_from_db()
218
+
219
+ if api_key:
220
+ # Try to create instance with stored API key
221
+ try:
222
+ from composio import Composio
223
+ from composio_langchain import LangchainProvider
224
+
225
+ temp_composio = Composio(
226
+ api_key=api_key,
227
+ provider=LangchainProvider()
228
+ )
229
+
230
+ # Test the instance
231
+ api_toolkits = await asyncio.to_thread(lambda: temp_composio.toolkits.get())
232
+
233
+ oauth2_toolkits = []
234
+
235
+ for toolkit in api_toolkits:
236
+ if hasattr(toolkit, 'auth_schemes') and 'OAUTH2' in toolkit.auth_schemes:
237
+ oauth2_toolkits.append(toolkit)
238
+
239
+ logger.info(f"Found {len(oauth2_toolkits)} OAuth2 toolkits from Composio API")
240
+
241
+ # Sync with database
242
+ for api_toolkit in oauth2_toolkits:
243
+ # Check if toolkit already exists
244
+ existing_toolkit = await ComposioToolkitQueries.get_toolkit_by_slug(db, api_toolkit.slug)
245
+
246
+ # Get metadata from toolkit
247
+ description = getattr(api_toolkit.meta, 'description', None) if hasattr(api_toolkit,
248
+ 'meta') else None
249
+ logo = getattr(api_toolkit.meta, 'logo', None) if hasattr(api_toolkit, 'meta') else None
250
+ app_url = getattr(api_toolkit.meta, 'app_url', None) if hasattr(api_toolkit, 'meta') else None
251
+
252
+ if not existing_toolkit:
253
+ # Create new toolkit
254
+ toolkit_data = await ComposioToolkitQueries.create_toolkit(
255
+ db=db,
256
+ name=api_toolkit.name,
257
+ slug=api_toolkit.slug,
258
+ description=description,
259
+ logo=logo,
260
+ app_url=app_url,
261
+ enabled=False,
262
+ tools=None
263
+ )
264
+ logger.info(f"Created new toolkit: {api_toolkit.name}")
265
+ else:
266
+ # Update existing toolkit information (but keep enabled status and tools)
267
+ update_data = {
268
+ 'name': api_toolkit.name,
269
+ 'description': description,
270
+ 'logo': logo,
271
+ 'app_url': app_url
272
+ }
273
+ await ComposioToolkitQueries.update_toolkit_by_slug(db, api_toolkit.slug, update_data)
274
+ logger.debug(f"Updated existing toolkit: {api_toolkit.name}")
275
+
276
+ await db.commit()
277
+
278
+ # Store valid instance
279
+ shared_state.composio_instance = temp_composio
280
+
281
+ logger.info("✅ Composio instance recreated from stored API key")
282
+ return {
283
+ "connected": True,
284
+ "key_valid": True,
285
+ "has_key": True,
286
+ "message": "Composio connection restored from stored API key",
287
+ "instance_available": True
288
+ }
289
+
290
+ except Exception as e:
291
+ logger.warning(f"Stored API key validation failed: {e}")
292
+ # Clear invalid stored key
293
+ shared_state.composio_instance = None
294
+ return {
295
+ "connected": False,
296
+ "key_valid": False,
297
+ "has_key": True,
298
+ "message": f"Stored API key is invalid: {str(e)}",
299
+ "instance_available": False
300
+ }
301
+
302
+ # No API key in database
303
+ return {
304
+ "connected": False,
305
+ "key_valid": False,
306
+ "has_key": False,
307
+ "message": "No Composio API key configured",
308
+ "instance_available": False
309
+ }
310
+
311
+ except Exception as e:
312
+ logger.error(f"Failed to check Composio status: {e}")
313
+ return {
314
+ "connected": False,
315
+ "key_valid": False,
316
+ "has_key": False,
317
+ "message": f"Status check failed: {str(e)}",
318
+ "instance_available": False
319
+ }
320
+
321
+
322
+ @router.post("/verify-key", response_model=ComposioKeyVerifyResponse)
323
+ async def verify_composio_api_key(
324
+ request: ComposioKeyVerifyRequest,
325
+ db: AsyncSession = Depends(get_db_session)
326
+ ):
327
+ """
328
+ Verify Composio API key validity and optionally store it
329
+ """
330
+ try:
331
+ from .. import shared_state
332
+ logger.info("Verifying Composio API key")
333
+
334
+ # Import Composio here to avoid startup dependencies
335
+ from composio import Composio
336
+ from composio_langchain import LangchainProvider
337
+
338
+ # Create temporary Composio instance for verification
339
+ try:
340
+ temp_composio = Composio(
341
+ api_key=request.api_key,
342
+ provider=LangchainProvider()
343
+ )
344
+
345
+ # Test the API key by getting toolkits
346
+ toolkits = await asyncio.to_thread(lambda: temp_composio.toolkits.get(slug='gmail'))
347
+
348
+ # If we get here, the API key is valid
349
+ logger.info("✅ Composio API key verified successfully")
350
+
351
+ # Store the valid API key in database
352
+ store_success = await _store_composio_api_key_in_db(request.api_key)
353
+
354
+ # Update shared state with new Composio instance
355
+ shared_state.composio_instance = temp_composio
356
+
357
+ return ComposioKeyVerifyResponse(
358
+ valid=True,
359
+ message="API key verified successfully" + (" and stored in database" if store_success else ""),
360
+ user_info={"toolkits_count": len(toolkits) if toolkits else 0}
361
+ )
362
+
363
+ except Exception as e:
364
+ logger.warning(f"Composio API key verification failed: {e}")
365
+ return ComposioKeyVerifyResponse(
366
+ valid=False,
367
+ message=f"Invalid API key: {str(e)}"
368
+ )
369
+
370
+ except Exception as e:
371
+ logger.error(f"Failed to verify Composio API key: {e}")
372
+ raise HTTPException(
373
+ status_code=500,
374
+ detail=f"Failed to verify Composio API key: {str(e)}"
375
+ )
376
+
377
+
378
+ @router.get("/toolkits", response_model=ComposioToolkitListResponse)
379
+ async def get_composio_toolkits(
380
+ sync_with_api: bool = False, # Changed default to False
381
+ db: AsyncSession = Depends(get_db_session)
382
+ ):
383
+ """
384
+ Get all OAuth2 toolkits from database and optionally sync with Composio API
385
+ """
386
+ try:
387
+ from .. import shared_state
388
+ logger.info(f"Getting Composio toolkits (sync_with_api={sync_with_api})")
389
+
390
+ synced_count = 0
391
+
392
+ # Get all toolkits from database
393
+ db_toolkits = await ComposioToolkitQueries.list_toolkits(db, enabled_only=False)
394
+ # Convert to response format
395
+ toolkit_responses = []
396
+ for toolkit in db_toolkits:
397
+ # Parse tools JSON if present
398
+ tools_data = None
399
+ if toolkit.tools:
400
+ try:
401
+ tools_data = json.loads(toolkit.tools)
402
+ except (json.JSONDecodeError, TypeError) as e:
403
+ logger.warning(f"Failed to parse tools for toolkit {toolkit.slug}: {e}")
404
+ tools_data = None
405
+
406
+ toolkit_responses.append(ComposioToolkitResponse(
407
+ id=toolkit.id,
408
+ name=toolkit.name,
409
+ slug=toolkit.slug,
410
+ description=toolkit.description,
411
+ logo=toolkit.logo,
412
+ app_url=toolkit.app_url,
413
+ enabled=toolkit.enabled,
414
+ tools=tools_data,
415
+ connection_status="unknown", # Will be updated by connection status check
416
+ created_at=toolkit.created_at.isoformat(),
417
+ updated_at=toolkit.updated_at.isoformat()
418
+ ))
419
+ logger.info(f"Found {len(toolkit_responses)} toolkits from Composio API")
420
+ return ComposioToolkitListResponse(
421
+ toolkits=toolkit_responses,
422
+ total_count=len(toolkit_responses),
423
+ synced_count=synced_count
424
+ )
425
+
426
+ except HTTPException:
427
+ raise
428
+ except Exception as e:
429
+ logger.error(f"Failed to get Composio toolkits: {e}")
430
+ raise HTTPException(
431
+ status_code=500,
432
+ detail=f"Failed to get Composio toolkits: {str(e)}"
433
+ )
434
+
435
+
436
+ @router.post("/toolkit/{slug}/toggle", response_model=ComposioToolkitToggleResponse)
437
+ async def toggle_composio_toolkit(
438
+ slug: str,
439
+ request: ComposioToolkitToggleRequest,
440
+ db: AsyncSession = Depends(get_db_session)
441
+ ):
442
+ """
443
+ Enable/disable a toolkit and handle OAuth flow if needed
444
+ """
445
+ try:
446
+ logger.info(f"Toggling toolkit {slug} to enabled={request.enabled}")
447
+
448
+ # Get toolkit from database
449
+ toolkit = await ComposioToolkitQueries.get_toolkit_by_slug(db, slug)
450
+ if not toolkit:
451
+ raise HTTPException(
452
+ status_code=404,
453
+ detail=f"Toolkit '{slug}' not found"
454
+ )
455
+
456
+ # Get Composio instance
457
+ composio = await _get_composio_instance()
458
+ if composio is None:
459
+ raise HTTPException(
460
+ status_code=400,
461
+ detail="Composio API key not configured. Please verify your API key first."
462
+ )
463
+
464
+ auth_url = None
465
+ connection_status = "disconnected"
466
+ entity_id = "default" # Use default entity ID
467
+
468
+ if request.enabled:
469
+ # Check if toolkit needs OAuth connection
470
+ try:
471
+ # Check for existing active connections using the new API
472
+ def _find_active_connection():
473
+ try:
474
+ connection_list = composio.connected_accounts.list(
475
+ user_ids=[entity_id],
476
+ toolkit_slugs=[slug.lower()]
477
+ )
478
+
479
+ if connection_list and hasattr(connection_list, "items") and connection_list.items:
480
+ for connection in connection_list.items:
481
+ connection_id = getattr(connection, "id", None)
482
+ connection_status = getattr(connection, "status", None)
483
+ if connection_status == "ACTIVE" and connection_id:
484
+ return connection_id, connection_status
485
+ return None, None
486
+ except Exception as e:
487
+ logger.error(f"Error checking connections: {e}")
488
+ return None, None
489
+
490
+ connection_id, conn_status = await asyncio.to_thread(_find_active_connection)
491
+
492
+ if not connection_id or request.force_reauth:
493
+ # Need to create OAuth connection
494
+ try:
495
+ def _create_auth_connection():
496
+ try:
497
+ # Get or create auth config
498
+ auth_configs = composio.auth_configs.list(toolkit_slug=slug)
499
+
500
+ auth_config_id = None
501
+ if len(auth_configs.items) == 0:
502
+ # Create new auth config
503
+ auth_config_response = composio.auth_configs.create(
504
+ toolkit=slug,
505
+ options={"type": "use_composio_managed_auth"}
506
+ )
507
+ auth_config_id = auth_config_response.id if hasattr(auth_config_response,
508
+ 'id') else auth_config_response
509
+ else:
510
+ # Use existing OAUTH2 auth config
511
+ for auth_config in auth_configs.items:
512
+ if auth_config.auth_scheme == "OAUTH2":
513
+ auth_config_id = auth_config.id
514
+ break
515
+
516
+ if not auth_config_id:
517
+ raise Exception("Could not find or create auth config")
518
+
519
+ # Initiate connection
520
+ connection_request = composio.connected_accounts.initiate(
521
+ user_id=entity_id,
522
+ auth_config_id=auth_config_id,
523
+ allow_multiple=True
524
+ )
525
+
526
+ return getattr(connection_request, 'redirect_url', None)
527
+
528
+ except Exception as e:
529
+ logger.error(f"Error creating auth connection: {e}")
530
+ raise e
531
+
532
+ auth_url = await asyncio.to_thread(_create_auth_connection)
533
+
534
+ if auth_url:
535
+ connection_status = "pending_auth"
536
+ logger.info(f"Generated OAuth URL for {slug}: {auth_url}")
537
+ else:
538
+ logger.warning(f"No OAuth URL returned for {slug}")
539
+ connection_status = "error"
540
+
541
+ except Exception as e:
542
+ logger.error(f"Failed to create OAuth connection for {slug}: {e}")
543
+ connection_status = "error"
544
+
545
+ else:
546
+ connection_status = "connected"
547
+ logger.info(f"Toolkit {slug} already has active connection")
548
+
549
+ except Exception as e:
550
+ logger.warning(f"Failed to check connections for {slug}: {e}")
551
+ connection_status = "unknown"
552
+
553
+ # If enabling and connected, fetch and save tools
554
+ if request.enabled and connection_status == "connected":
555
+ try:
556
+ entity_id = "default" # Use default entity ID
557
+ api_tools = await asyncio.to_thread(
558
+ lambda: composio.tools.get(user_id=entity_id, toolkits=[slug.lower()], limit=999)
559
+ )
560
+
561
+ # Convert to response format
562
+ tools_list = []
563
+ for tool in api_tools:
564
+ tools_list.append({
565
+ 'name': tool.name,
566
+ 'description': getattr(tool, 'description', ''),
567
+ 'parameters': tool.args_schema.model_json_schema() if hasattr(tool, 'args_schema') else {},
568
+ 'enabled': True # Default enabled
569
+ })
570
+
571
+ # Save tools to database
572
+ if tools_list:
573
+ try:
574
+ tools_json = json.dumps(tools_list)
575
+ await ComposioToolkitQueries.update_toolkit_tools(
576
+ db,
577
+ toolkit.id,
578
+ tools_json
579
+ )
580
+ logger.info(f"Synced {len(tools_list)} tools for toolkit {slug}")
581
+ except Exception as e:
582
+ logger.warning(f"Failed to save tools to database for toolkit {slug}: {e}")
583
+
584
+ except Exception as e:
585
+ logger.warning(f"Failed to fetch tools for toolkit {slug}: {e}")
586
+
587
+ # Update toolkit enabled status in database
588
+ update_data = {'enabled': request.enabled}
589
+ if request.enabled and connection_status == "connected" and 'tools_list' in locals():
590
+ # Also update tools if we fetched them
591
+ update_data['tools'] = json.dumps(tools_list) if tools_list else None
592
+
593
+ success = await ComposioToolkitQueries.update_toolkit_by_slug(
594
+ db,
595
+ slug,
596
+ update_data
597
+ )
598
+
599
+ if not success:
600
+ raise HTTPException(
601
+ status_code=500,
602
+ detail="Failed to update toolkit status in database"
603
+ )
604
+
605
+ await db.commit()
606
+
607
+ message = f"Toolkit '{toolkit.name}' {'enabled' if request.enabled else 'disabled'} successfully"
608
+ requires_oauth = auth_url is not None
609
+ is_connected = connection_status == "connected"
610
+
611
+ if auth_url:
612
+ message += ". Please complete OAuth authentication."
613
+
614
+ logger.info(f"✅ {message}")
615
+
616
+ return ComposioToolkitToggleResponse(
617
+ success=True,
618
+ message=message,
619
+ enabled=request.enabled,
620
+ requires_oauth=requires_oauth,
621
+ oauth_url=auth_url,
622
+ connected=is_connected,
623
+ connection_status=connection_status
624
+ )
625
+
626
+ except HTTPException:
627
+ raise
628
+ except Exception as e:
629
+ logger.error(f"Failed to toggle toolkit {slug}: {e}")
630
+ raise HTTPException(
631
+ status_code=500,
632
+ detail=f"Failed to toggle toolkit: {str(e)}"
633
+ )
634
+
635
+
636
+ @router.get("/toolkit/{slug}/tools", response_model=ComposioToolsResponse)
637
+ async def get_toolkit_tools(
638
+ slug: str,
639
+ db: AsyncSession = Depends(get_db_session)
640
+ ):
641
+ """
642
+ Get available tools for a specific toolkit
643
+ First tries to get from database, if empty then fetches from API and saves to database
644
+ """
645
+ try:
646
+ logger.info(f"Getting tools for toolkit {slug}")
647
+
648
+ # Get toolkit from database
649
+ toolkit = await ComposioToolkitQueries.get_toolkit_by_slug(db, slug)
650
+ if not toolkit:
651
+ raise HTTPException(
652
+ status_code=404,
653
+ detail=f"Toolkit '{slug}' not found"
654
+ )
655
+ # First, try to get tools from database
656
+ if toolkit.tools:
657
+ try:
658
+ tools_list = json.loads(toolkit.tools)
659
+ if tools_list:
660
+ logger.info(f"Found {len(tools_list)} tools for toolkit {slug} from database")
661
+ return ComposioToolsResponse(
662
+ toolkit_slug=slug,
663
+ tools=tools_list,
664
+ total_tools=len(tools_list)
665
+ )
666
+
667
+ except (json.JSONDecodeError, TypeError) as e:
668
+ logger.warning(f"Failed to parse existing tools from database for {slug}: {e}")
669
+
670
+ # If we reach here, either no tools in database or invalid format
671
+ # Get Composio instance and fetch from API
672
+ composio = await _get_composio_instance()
673
+ if composio is None:
674
+ raise HTTPException(
675
+ status_code=400,
676
+ detail="Composio API key not configured. Please verify your API key first."
677
+ )
678
+
679
+ # Get tools from Composio API
680
+ try:
681
+ entity_id = "default" # Use default entity ID
682
+ api_tools = await asyncio.to_thread(
683
+ lambda: composio.tools.get(user_id=entity_id, toolkits=[slug.lower()], limit=999)
684
+ )
685
+
686
+ # Convert to response format
687
+ tools_list = []
688
+ for tool in api_tools:
689
+ tools_list.append({
690
+ 'name': tool.name,
691
+ 'description': getattr(tool, 'description', ''),
692
+ 'parameters': tool.args_schema.model_json_schema() if hasattr(tool, 'args_schema') else {},
693
+ 'enabled': True # Default enabled
694
+ })
695
+
696
+ # Save tools to database for future use
697
+ try:
698
+ tools_json = json.dumps(tools_list)
699
+ success = await ComposioToolkitQueries.update_toolkit_tools(
700
+ db,
701
+ toolkit.id,
702
+ tools_json
703
+ )
704
+ if success:
705
+ await db.commit()
706
+ else:
707
+ logger.warning(f"Failed to save tools to database for toolkit {slug}")
708
+ except Exception as e:
709
+ logger.warning(f"Failed to save tools to database for toolkit {slug}: {e}")
710
+
711
+ logger.info(f"Found {len(tools_list)} tools for toolkit {slug} from API and saved to database")
712
+
713
+ return ComposioToolsResponse(
714
+ toolkit_slug=slug,
715
+ tools=tools_list,
716
+ total_tools=len(tools_list)
717
+ )
718
+
719
+ except Exception as e:
720
+ logger.error(f"Failed to get tools for toolkit {slug} from API: {e}")
721
+ raise HTTPException(
722
+ status_code=500,
723
+ detail=f"Failed to get tools from Composio API: {str(e)}"
724
+ )
725
+
726
+ except HTTPException:
727
+ raise
728
+ except Exception as e:
729
+ logger.error(f"Failed to get toolkit tools for {slug}: {e}")
730
+ raise HTTPException(
731
+ status_code=500,
732
+ detail=f"Failed to get toolkit tools: {str(e)}"
733
+ )
734
+
735
+
736
+ @router.post("/toolkit/{slug}/tools", response_model=ComposioToolsResponse)
737
+ async def update_toolkit_tools(
738
+ slug: str,
739
+ request: ComposioToolsUpdateRequest,
740
+ db: AsyncSession = Depends(get_db_session)
741
+ ):
742
+ """
743
+ Update selected tools for a toolkit
744
+ """
745
+ try:
746
+ logger.info(f"Updating tools selection for toolkit {slug}")
747
+ logger.info(f"Request selected_tools: {request.selected_tools}")
748
+
749
+ # Get toolkit from database
750
+ toolkit = await ComposioToolkitQueries.get_toolkit_by_slug(db, slug)
751
+ if not toolkit:
752
+ raise HTTPException(
753
+ status_code=404,
754
+ detail=f"Toolkit '{slug}' not found"
755
+ )
756
+
757
+ logger.info(f"Found toolkit: {toolkit.name} (ID: {toolkit.id})")
758
+ logger.info(f"Existing tools in DB: {toolkit.tools}")
759
+
760
+ tools_list = []
761
+ if toolkit.tools:
762
+ try:
763
+ tools_list = json.loads(toolkit.tools)
764
+ logger.info(f"Parsed existing tools: {len(tools_list)} tools")
765
+ if tools_list:
766
+ for tool in tools_list:
767
+ original_enabled = tool.get('enabled', True)
768
+ new_enabled = request.selected_tools.get(tool['name'], True)
769
+ tool['enabled'] = new_enabled
770
+ logger.info(f"Tool {tool['name']}: {original_enabled} -> {new_enabled}")
771
+ except Exception as e:
772
+ logger.error(f"Failed to parse existing tools: {e}")
773
+ tools_list = []
774
+
775
+ if tools_list:
776
+ # Convert selected tools to JSON string
777
+ tools_json = json.dumps(tools_list)
778
+ logger.info(f"Tools JSON to save: {tools_json[:200]}...") # Log first 200 chars
779
+ else:
780
+ tools_json = ''
781
+ logger.info("No tools to save, using empty string")
782
+
783
+ # Update toolkit tools in database
784
+ logger.info(f"Calling update_toolkit_tools with toolkit_id: {toolkit.id}")
785
+ success = await ComposioToolkitQueries.update_toolkit_tools(
786
+ db,
787
+ toolkit.id,
788
+ tools_json
789
+ )
790
+
791
+ if not success:
792
+ logger.error(f"Failed to update toolkit tools in database for {slug}")
793
+ raise HTTPException(
794
+ status_code=500,
795
+ detail="Failed to update toolkit tools in database"
796
+ )
797
+
798
+ await db.commit()
799
+ logger.info(f"✅ Database commit successful for {slug}")
800
+
801
+ # Get updated tools count
802
+ enabled_count = sum(1 for enabled in request.selected_tools.values() if enabled)
803
+ total_count = len(request.selected_tools)
804
+
805
+ logger.info(f"✅ Updated tools selection for {slug}: {enabled_count}/{total_count} tools enabled")
806
+
807
+ # Return current tools (reuse the get endpoint logic)
808
+ result = await get_toolkit_tools(slug, db)
809
+ logger.info(f"Returning updated tools response for {slug}")
810
+ return result
811
+
812
+ except HTTPException:
813
+ raise
814
+ except Exception as e:
815
+ logger.error(f"Failed to update toolkit tools for {slug}: {e}")
816
+ raise HTTPException(
817
+ status_code=500,
818
+ detail=f"Failed to update toolkit tools: {str(e)}"
819
+ )
820
+
821
+
822
+ @router.get("/toolkit/{slug}/connection-status", response_model=ComposioConnectionStatusResponse)
823
+ async def get_toolkit_connection_status(
824
+ slug: str,
825
+ db: AsyncSession = Depends(get_db_session)
826
+ ):
827
+ """
828
+ Check connection status for a specific toolkit
829
+ """
830
+ try:
831
+ logger.info(f"Checking connection status for toolkit {slug}")
832
+
833
+ # Get toolkit from database
834
+ toolkit = await ComposioToolkitQueries.get_toolkit_by_slug(db, slug)
835
+ if not toolkit:
836
+ raise HTTPException(
837
+ status_code=404,
838
+ detail=f"Toolkit '{slug}' not found"
839
+ )
840
+
841
+ # Get Composio instance
842
+ composio = await _get_composio_instance()
843
+ if composio is None:
844
+ return ComposioConnectionStatusResponse(
845
+ toolkit_slug=slug,
846
+ connected=False,
847
+ connection_id=None,
848
+ status="no_api_key",
849
+ last_checked=datetime.now().isoformat()
850
+ )
851
+
852
+ # Check connection status with Composio API
853
+ try:
854
+ entity_id = "default" # Use default entity ID
855
+
856
+ def _check_connection_status():
857
+ try:
858
+ connection_list = composio.connected_accounts.list(
859
+ user_ids=[entity_id],
860
+ toolkit_slugs=[slug.lower()]
861
+ )
862
+
863
+ if connection_list and hasattr(connection_list, "items") and connection_list.items:
864
+ for connection in connection_list.items:
865
+ connection_id = getattr(connection, "id", None)
866
+ connection_status = getattr(connection, "status", None)
867
+ if connection_status == "ACTIVE" and connection_id:
868
+ return connection_id, "connected"
869
+ return None, "disconnected"
870
+ except Exception as e:
871
+ logger.error(f"Error checking connection status: {e}")
872
+ return None, "error"
873
+
874
+ connection_id, status = await asyncio.to_thread(_check_connection_status)
875
+
876
+ return ComposioConnectionStatusResponse(
877
+ toolkit_slug=slug,
878
+ connected=(status == "connected"),
879
+ connection_id=connection_id,
880
+ status=status,
881
+ last_checked=datetime.now().isoformat()
882
+ )
883
+
884
+ except Exception as e:
885
+ logger.error(f"Failed to check connection status for {slug}: {e}")
886
+ return ComposioConnectionStatusResponse(
887
+ toolkit_slug=slug,
888
+ connected=False,
889
+ connection_id=None,
890
+ status="error",
891
+ last_checked=datetime.now().isoformat()
892
+ )
893
+
894
+ except HTTPException:
895
+ raise
896
+ except Exception as e:
897
+ logger.error(f"Failed to get connection status for {slug}: {e}")
898
+ raise HTTPException(
899
+ status_code=500,
900
+ detail=f"Failed to get connection status: {str(e)}"
901
+ )
902
+
903
+
904
+ # OAuth is now handled via browser popups using Composio's managed auth system
905
+ # No callback endpoint needed as authentication is handled in popup windows
906
+
907
+ # Health check endpoint
908
+ @router.get("/health")
909
+ async def composio_health_check():
910
+ """
911
+ Check Composio integration health
912
+ """
913
+ try:
914
+ composio = await _get_composio_instance()
915
+
916
+ if composio is None:
917
+ return {
918
+ "status": "no_api_key",
919
+ "message": "Composio API key not configured",
920
+ "timestamp": datetime.now().isoformat()
921
+ }
922
+
923
+ # Test API connection
924
+ # try:
925
+ # toolkits = await asyncio.to_thread(lambda: composio.toolkits.get())
926
+ # return {
927
+ # "status": "healthy",
928
+ # "message": "Composio API connection working",
929
+ # "toolkits_count": len(toolkits) if toolkits else 0,
930
+ # "timestamp": datetime.now().isoformat()
931
+ # }
932
+ # except Exception as e:
933
+ # return {
934
+ # "status": "api_error",
935
+ # "message": f"Composio API error: {str(e)}",
936
+ # "timestamp": datetime.now().isoformat()
937
+ # }
938
+
939
+ return {
940
+ "status": "healthy",
941
+ "message": "Composio API connection working",
942
+ "toolkits_count": 0,
943
+ "timestamp": datetime.now().isoformat()
944
+ }
945
+
946
+ except Exception as e:
947
+ logger.error(f"Composio health check failed: {e}")
948
+ return {
949
+ "status": "error",
950
+ "message": f"Health check failed: {str(e)}",
951
+ "timestamp": datetime.now().isoformat()
952
+ }