langchain-trigger-server 0.1.0__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.

Potentially problematic release.


This version of langchain-trigger-server might be problematic. Click here for more details.

@@ -0,0 +1,657 @@
1
+ """FastAPI application for trigger server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from typing import Any, Callable, Dict, List, Optional
8
+
9
+ import httpx
10
+
11
+ from fastapi import FastAPI, HTTPException, Request, Depends
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from starlette.middleware.base import BaseHTTPMiddleware
14
+ from starlette.responses import Response
15
+
16
+ from .core import UserAuthInfo, ProviderAuthInfo, MetadataManager
17
+ from .decorators import TriggerTemplate
18
+ from .database import create_database, TriggerDatabaseInterface
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class AuthenticationMiddleware(BaseHTTPMiddleware):
24
+ """Middleware to handle authentication for API endpoints."""
25
+
26
+ def __init__(self, app, auth_handler: Callable):
27
+ super().__init__(app)
28
+ self.auth_handler = auth_handler
29
+
30
+ async def dispatch(self, request: Request, call_next):
31
+ # Skip auth for webhooks, health/root endpoints, and OPTIONS requests
32
+ if (request.url.path.startswith("/webhooks/") or
33
+ request.url.path in ["/", "/health"] or
34
+ request.method == "OPTIONS"):
35
+ return await call_next(request)
36
+
37
+ try:
38
+ # Run mandatory custom authentication
39
+ identity = await self.auth_handler({}, dict(request.headers))
40
+
41
+ if not identity or not identity.get("identity"):
42
+ return Response(
43
+ content='{"detail": "Authentication required"}',
44
+ status_code=401,
45
+ media_type="application/json"
46
+ )
47
+
48
+ # Store identity in request state for endpoints to access
49
+ request.state.current_user = identity
50
+
51
+ except Exception as e:
52
+ logger.error(f"Authentication middleware error: {e}")
53
+ return Response(
54
+ content='{"detail": "Authentication failed"}',
55
+ status_code=401,
56
+ media_type="application/json"
57
+ )
58
+
59
+ return await call_next(request)
60
+
61
+
62
+ def get_current_user(request: Request) -> Dict[str, Any]:
63
+ """FastAPI dependency to get the current authenticated user."""
64
+ if not hasattr(request.state, "current_user"):
65
+ raise HTTPException(status_code=401, detail="Authentication required")
66
+ return request.state.current_user
67
+
68
+
69
+ class TriggerServer:
70
+ """FastAPI application for trigger webhooks."""
71
+
72
+ def __init__(
73
+ self,
74
+ auth_handler: Callable,
75
+ cors_origins: Optional[List[str]] = None,
76
+ database: Optional[TriggerDatabaseInterface] = None,
77
+ ):
78
+ self.app = FastAPI(
79
+ title="Triggers Server",
80
+ description="Event-driven triggers framework",
81
+ version="0.1.0"
82
+ )
83
+
84
+ self.database = database or create_database() # Default to Supabase
85
+ self.auth_handler = auth_handler
86
+
87
+ # LangGraph configuration
88
+ self.langgraph_api_url = os.getenv("LANGGRAPH_API_URL")
89
+ self.langgraph_api_key = os.getenv("LANGCHAIN_API_KEY")
90
+
91
+ if not self.langgraph_api_url:
92
+ raise ValueError("LANGGRAPH_API_URL environment variable is required")
93
+
94
+ self.langgraph_api_url = self.langgraph_api_url.rstrip("/")
95
+
96
+ self.langchain_auth_client = None
97
+ try:
98
+ from langchain_auth import Client
99
+ langchain_api_key = os.getenv("LANGCHAIN_API_KEY")
100
+ if langchain_api_key:
101
+ self.langchain_auth_client = Client(api_key=langchain_api_key)
102
+ logger.info("Initialized LangChain Auth client for OAuth token injection")
103
+ else:
104
+ logger.warning("LANGCHAIN_API_KEY not found - OAuth token injection disabled")
105
+ except ImportError:
106
+ logger.warning("langchain_auth not installed - OAuth token injection disabled")
107
+
108
+ self.triggers: List[TriggerTemplate] = []
109
+
110
+ # Setup CORS
111
+ if cors_origins is None:
112
+ cors_origins = ["*"] # Allow all origins by default
113
+
114
+ self.app.add_middleware(
115
+ CORSMiddleware,
116
+ allow_origins=cors_origins,
117
+ allow_credentials=True,
118
+ allow_methods=["*"],
119
+ allow_headers=["*"],
120
+ )
121
+
122
+ # Setup authentication middleware
123
+ self.app.add_middleware(AuthenticationMiddleware, auth_handler=auth_handler)
124
+
125
+ # Setup routes
126
+ self._setup_routes()
127
+
128
+ # Add startup event to ensure trigger templates exist in database
129
+ @self.app.on_event("startup")
130
+ async def startup_event():
131
+ await self.ensure_trigger_templates()
132
+
133
+ def add_trigger(self, trigger: TriggerTemplate) -> None:
134
+ """Add a trigger template to the app."""
135
+ # Check for duplicate IDs
136
+ if any(t.id == trigger.id for t in self.triggers):
137
+ raise ValueError(f"Trigger with id '{trigger.id}' already exists")
138
+
139
+ self.triggers.append(trigger)
140
+
141
+ if trigger.trigger_handler:
142
+ async def handler_endpoint(request: Request) -> Dict[str, Any]:
143
+ return await self._handle_request(trigger, request)
144
+
145
+ handler_path = f"/webhooks/{trigger.id}"
146
+ self.app.post(handler_path)(handler_endpoint)
147
+ logger.info(f"Added handler route: POST {handler_path}")
148
+
149
+ logger.info(f"Registered trigger template in memory: {trigger.name} ({trigger.id})")
150
+
151
+ async def ensure_trigger_templates(self) -> None:
152
+ """Ensure all registered trigger templates exist in the database."""
153
+ for trigger in self.triggers:
154
+ existing = await self.database.get_trigger_template(trigger.id)
155
+ if not existing:
156
+ logger.info(f"Creating new trigger template in database: {trigger.name} ({trigger.id})")
157
+ await self.database.create_trigger_template(
158
+ id=trigger.id,
159
+ name=trigger.name,
160
+ description=trigger.description,
161
+ registration_schema=trigger.registration_model.model_json_schema()
162
+ )
163
+ logger.info(f"✓ Successfully created trigger template: {trigger.name} ({trigger.id})")
164
+ else:
165
+ logger.info(f"✓ Trigger template already exists in database: {trigger.name} ({trigger.id})")
166
+
167
+ def add_triggers(self, triggers: List[TriggerTemplate]) -> None:
168
+ """Add multiple triggers."""
169
+ for trigger in triggers:
170
+ self.add_trigger(trigger)
171
+
172
+ def _setup_routes(self) -> None:
173
+ """Setup built-in API routes."""
174
+
175
+ @self.app.get("/")
176
+ async def root() -> Dict[str, str]:
177
+ return {"message": "Triggers Server", "version": "0.1.0"}
178
+
179
+ @self.app.get("/health")
180
+ async def health() -> Dict[str, str]:
181
+ return {"status": "healthy"}
182
+
183
+ @self.app.get("/api/triggers")
184
+ async def api_list_triggers() -> Dict[str, Any]:
185
+ """List available trigger templates."""
186
+ templates = await self.database.get_trigger_templates()
187
+ trigger_list = []
188
+ for template in templates:
189
+ trigger_list.append({
190
+ "id": template["id"],
191
+ "displayName": template["name"],
192
+ "description": template["description"],
193
+ "path": "/api/triggers/registrations",
194
+ "method": "POST",
195
+ "payloadSchema": template.get("registration_schema", {}),
196
+ })
197
+
198
+ return {
199
+ "success": True,
200
+ "data": trigger_list
201
+ }
202
+
203
+ @self.app.get("/api/triggers/registrations")
204
+ async def api_list_registrations(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
205
+ """List user's trigger registrations (user-scoped)."""
206
+ try:
207
+ user_id = current_user["identity"]
208
+
209
+ # Get user's trigger registrations using new schema
210
+ user_registrations = await self.database.get_user_trigger_registrations(user_id)
211
+
212
+ # Format response to match expected structure
213
+ registrations = []
214
+ for reg in user_registrations:
215
+ # Get linked agent IDs
216
+ linked_agent_ids = await self.database.get_agents_for_trigger(reg["id"])
217
+
218
+ registrations.append({
219
+ "id": reg["id"],
220
+ "user_id": reg["user_id"],
221
+ "template_id": reg.get("trigger_templates", {}).get("id"),
222
+ "resource": reg["resource"],
223
+ "linked_assistant_ids": linked_agent_ids, # For backward compatibility
224
+ "created_at": reg["created_at"]
225
+ })
226
+
227
+ return {
228
+ "success": True,
229
+ "data": registrations
230
+ }
231
+
232
+ except HTTPException:
233
+ raise
234
+ except Exception as e:
235
+ logger.error(f"Error listing registrations: {e}")
236
+ raise HTTPException(status_code=500, detail=str(e))
237
+
238
+ @self.app.post("/api/triggers/registrations")
239
+ async def api_create_registration(request: Request, current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
240
+ """Create a new trigger registration."""
241
+ try:
242
+ payload = await request.json()
243
+ logger.info(f"Registration payload received: {payload}")
244
+
245
+ user_id = current_user["identity"]
246
+ trigger_id = payload.get("type")
247
+ if not trigger_id:
248
+ raise HTTPException(status_code=400, detail="Missing required field: type")
249
+
250
+ trigger = next((t for t in self.triggers if t.id == trigger_id), None)
251
+ if not trigger:
252
+ raise HTTPException(status_code=400, detail=f"Unknown trigger type: {trigger_id}")
253
+
254
+ # Inject OAuth tokens if needed for registration
255
+ auth_user = None
256
+ if trigger.oauth_providers:
257
+ try:
258
+ auth_user = await self._get_authenticated_user(trigger, user_id)
259
+
260
+ # Check if any provider requires authentication - return early if so
261
+ for provider in trigger.oauth_providers.keys():
262
+ provider_info = auth_user.providers.get(provider)
263
+ if provider_info and provider_info.auth_required:
264
+ logger.info(f"User {user_id} needs to authenticate for {provider} - returning auth URL")
265
+ return {
266
+ "success": True,
267
+ "registered": False,
268
+ "auth_required": True,
269
+ "auth_url": provider_info.auth_url,
270
+ "auth_id": provider_info.auth_id,
271
+ "provider": provider
272
+ }
273
+
274
+ except Exception as e:
275
+ logger.error(f"OAuth authentication failed during registration: {e}")
276
+ raise HTTPException(status_code=500, detail="OAuth authentication failed")
277
+
278
+
279
+ # Parse payload into registration model first
280
+ try:
281
+ registration_instance = trigger.registration_model(**payload)
282
+ except Exception as e:
283
+ raise HTTPException(
284
+ status_code=400,
285
+ detail=f"Invalid payload for trigger {trigger_type}: {str(e)}"
286
+ )
287
+
288
+ # Call the trigger's registration handler with parsed registration model
289
+ result = await trigger.registration_handler(registration_instance, auth_user)
290
+
291
+ resource_dict = registration_instance.model_dump()
292
+ registration = await self.database.create_trigger_registration(
293
+ user_id=user_id,
294
+ template_id=trigger.id,
295
+ resource=resource_dict,
296
+ metadata=result.metadata
297
+ )
298
+
299
+ if not registration:
300
+ raise HTTPException(status_code=500, detail="Failed to create trigger registration")
301
+
302
+ # Return registration result
303
+ return {
304
+ "success": True,
305
+ "data": registration,
306
+ "metadata": result.metadata
307
+ }
308
+
309
+ except HTTPException:
310
+ raise
311
+ except Exception as e:
312
+ logger.error(f"Error creating trigger registration: {e}")
313
+ raise HTTPException(status_code=500, detail=str(e))
314
+
315
+ @self.app.get("/api/triggers/registrations/{registration_id}/agents")
316
+ async def api_list_registration_agents(registration_id: str, current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
317
+ """List agents linked to this registration."""
318
+ try:
319
+ user_id = current_user["identity"]
320
+
321
+ # Get the specific trigger registration
322
+ trigger = await self.database.get_user_trigger(user_id, registration_id, token)
323
+ if not trigger:
324
+ raise HTTPException(status_code=404, detail="Trigger registration not found or access denied")
325
+
326
+ # Return the linked agent IDs
327
+ return {
328
+ "success": True,
329
+ "data": trigger.get("linked_assistant_ids", [])
330
+ }
331
+
332
+ except HTTPException:
333
+ raise
334
+ except Exception as e:
335
+ logger.error(f"Error getting registration agents: {e}")
336
+ raise HTTPException(status_code=500, detail=str(e))
337
+
338
+ @self.app.post("/api/triggers/registrations/{registration_id}/agents/{agent_id}")
339
+ async def api_add_agent_to_trigger(registration_id: str, agent_id: str, request: Request, current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
340
+ """Add an agent to a trigger registration."""
341
+ try:
342
+ # Parse request body for field selection
343
+ try:
344
+ body = await request.json()
345
+ field_selection = body.get("field_selection")
346
+ except:
347
+ field_selection = None
348
+
349
+ user_id = current_user["identity"]
350
+
351
+ # Verify the trigger registration exists and belongs to the user
352
+ registration = await self.database.get_trigger_registration(registration_id, user_id)
353
+ if not registration:
354
+ raise HTTPException(status_code=404, detail="Trigger registration not found or access denied")
355
+
356
+ # Link the agent to the trigger
357
+ success = await self.database.link_agent_to_trigger(
358
+ agent_id=agent_id,
359
+ registration_id=registration_id,
360
+ created_by=user_id,
361
+ field_selection=field_selection
362
+ )
363
+
364
+ if not success:
365
+ raise HTTPException(status_code=500, detail="Failed to link agent to trigger")
366
+
367
+ return {
368
+ "success": True,
369
+ "message": f"Successfully linked agent {agent_id} to trigger {registration_id}",
370
+ "data": {
371
+ "registration_id": registration_id,
372
+ "agent_id": agent_id
373
+ }
374
+ }
375
+
376
+ except HTTPException:
377
+ raise
378
+ except Exception as e:
379
+ logger.error(f"Error linking agent to trigger: {e}")
380
+ raise HTTPException(status_code=500, detail=str(e))
381
+
382
+ @self.app.delete("/api/triggers/registrations/{registration_id}/agents/{agent_id}")
383
+ async def api_remove_agent_from_trigger(registration_id: str, agent_id: str, current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
384
+ """Remove an agent from a trigger registration."""
385
+ try:
386
+ user_id = current_user["identity"]
387
+
388
+ # Verify the trigger registration exists and belongs to the user
389
+ registration = await self.database.get_trigger_registration(registration_id, user_id)
390
+ if not registration:
391
+ raise HTTPException(status_code=404, detail="Trigger registration not found or access denied")
392
+
393
+ # Unlink the agent from the trigger
394
+ success = await self.database.unlink_agent_from_trigger(
395
+ agent_id=agent_id,
396
+ registration_id=registration_id
397
+ )
398
+
399
+ if not success:
400
+ raise HTTPException(status_code=500, detail="Failed to unlink agent from trigger")
401
+
402
+ return {
403
+ "success": True,
404
+ "message": f"Successfully unlinked agent {agent_id} from trigger {registration_id}",
405
+ "data": {
406
+ "registration_id": registration_id,
407
+ "agent_id": agent_id
408
+ }
409
+ }
410
+
411
+ except HTTPException:
412
+ raise
413
+ except Exception as e:
414
+ logger.error(f"Error unlinking agent from trigger: {e}")
415
+ raise HTTPException(status_code=500, detail=str(e))
416
+
417
+ @self.app.get("/events/subscriptions")
418
+ async def list_event_subscriptions() -> Dict[str, Any]:
419
+ """List event bus subscriptions."""
420
+ if hasattr(self.event_bus, "list_subscriptions"):
421
+ subscriptions = self.event_bus.list_subscriptions()
422
+ else:
423
+ subscriptions = {}
424
+
425
+ return {"subscriptions": subscriptions}
426
+
427
+
428
+ async def _handle_request(
429
+ self,
430
+ trigger: TriggerTemplate,
431
+ request: Request
432
+ ) -> Dict[str, Any]:
433
+ """Handle an incoming request with a handler function."""
434
+ try:
435
+ # Parse request data
436
+ if request.method == "POST":
437
+ if request.headers.get("content-type", "").startswith("application/json"):
438
+ payload = await request.json()
439
+ else:
440
+ # Handle form data or other content types
441
+ body = await request.body()
442
+ payload = {"raw_body": body.decode("utf-8") if body else ""}
443
+ else:
444
+ payload = dict(request.query_params)
445
+
446
+ # Step 1: Registration resolution
447
+ if not trigger.registration_resolver:
448
+ raise HTTPException(
449
+ status_code=500,
450
+ detail=f"Trigger {trigger.id} missing required registration_resolver"
451
+ )
452
+
453
+ # Extract resource identifiers from webhook payload
454
+ resource_data = await trigger.registration_resolver(payload)
455
+
456
+ # Find matching registration
457
+ # Convert Pydantic model to dict for database lookup
458
+ resource_dict = resource_data.model_dump()
459
+ registration = await self.database.find_registration_by_resource(
460
+ trigger.id,
461
+ resource_dict
462
+ )
463
+
464
+ if not registration:
465
+ logger.warning(f"No registration found for trigger_id={trigger.id} with resource={resource_data}, returning 400")
466
+ raise HTTPException(
467
+ status_code=400,
468
+ detail=f"No registration found for {trigger.id} with resource {resource_data}"
469
+ )
470
+
471
+ # Step 2: Get user_id from registration (webhooks don't use API auth)
472
+ user_id = registration["user_id"]
473
+
474
+ # Step 3: Inject OAuth tokens if needed
475
+ auth_user = None
476
+ if trigger.oauth_providers and self.langchain_auth_client:
477
+ try:
478
+ auth_user = await self._get_authenticated_user(trigger, user_id)
479
+
480
+ # Check if any provider requires re-authentication - this shouldn't happen in webhooks
481
+ for provider in trigger.oauth_providers.keys():
482
+ provider_info = auth_user.providers.get(provider)
483
+ if provider_info and provider_info.auth_required:
484
+ logger.error(f"User {user_id} needs to re-authenticate for {provider} - this should have been handled during registration")
485
+ return {
486
+ "success": False,
487
+ "error": f"Authentication required for {provider}",
488
+ "message": "User needs to re-authenticate this trigger"
489
+ }
490
+
491
+ except Exception as e:
492
+ logger.error(f"OAuth authentication failed: {e}")
493
+ # Continue without auth - triggers can handle missing tokens
494
+
495
+ # Step 4: Create metadata manager
496
+ metadata_manager = MetadataManager(
497
+ database=self.database,
498
+ registration_id=registration["id"],
499
+ initial_metadata=registration.get("metadata", {})
500
+ )
501
+
502
+ # Step 5: Call handler with parsed registration data
503
+ result = await trigger.trigger_handler(payload, resource_data, auth_user, metadata_manager)
504
+ registration_id = registration["id"]
505
+
506
+ # Check if we should invoke agents
507
+ if not result.invoke_agent:
508
+ logger.info(f"Handler requested no agent invocation for registration {registration_id}")
509
+ return {
510
+ "success": True,
511
+ "agents_invoked": 0
512
+ }
513
+
514
+ # Get agents linked to this trigger registration
515
+ agent_links = await self.database.get_agents_for_trigger(registration_id)
516
+
517
+ if not agent_links:
518
+ logger.info(f"No agents linked to registration {registration_id}")
519
+ return {
520
+ "success": True,
521
+ "agents_invoked": 0
522
+ }
523
+
524
+ logger.info(f"Processing trigger result for registration {registration_id} with {len(agent_links)} linked agents")
525
+
526
+ # Invoke each linked agent
527
+ agents_invoked = 0
528
+ for agent_link in agent_links:
529
+ agent_id = agent_link if isinstance(agent_link, str) else agent_link.get("agent_id")
530
+
531
+ # Use the data string from TriggerHandlerResult directly
532
+ agent_input = {
533
+ "messages": [
534
+ {"role": "human", "content": result.data}
535
+ ]
536
+ }
537
+
538
+ try:
539
+ await self._invoke_agent(
540
+ agent_id=agent_id,
541
+ user_id=registration["user_id"],
542
+ input_data=agent_input,
543
+ )
544
+ agents_invoked += 1
545
+ except Exception as e:
546
+ logger.error(f"Error invoking agent {agent_id}: {e}", exc_info=True)
547
+
548
+ logger.info(f"Processed trigger handler, invoked {agents_invoked} agents")
549
+
550
+ return {
551
+ "success": True,
552
+ "agents_invoked": agents_invoked
553
+ }
554
+
555
+ except HTTPException:
556
+ raise
557
+ except Exception as e:
558
+ logger.error(f"Error in trigger handler: {e}", exc_info=True)
559
+ raise HTTPException(
560
+ status_code=500,
561
+ detail=f"Trigger processing failed: {str(e)}"
562
+ )
563
+
564
+
565
+ async def _invoke_agent(
566
+ self,
567
+ agent_id: str,
568
+ user_id: str,
569
+ input_data: Dict[str, Any],
570
+ ) -> Dict[str, Any]:
571
+ """Invoke LangGraph agent directly."""
572
+ headers = {
573
+ "Content-Type": "application/json",
574
+ "x-auth-scheme": "langchain-trigger-server-event",
575
+ }
576
+
577
+ # Add API key if provided
578
+ if self.langgraph_api_key:
579
+ headers["Authorization"] = f"Bearer {self.langgraph_api_key}"
580
+
581
+ # Add user-specific headers
582
+ if user_id:
583
+ headers["x-supabase-user-id"] = user_id
584
+
585
+ payload = {
586
+ "input": input_data,
587
+ "assistant_id": agent_id,
588
+ "metadata": {
589
+ "triggered_by": "langchain-triggers",
590
+ "user_id": user_id,
591
+ },
592
+ }
593
+
594
+ # Let LangGraph create a new thread automatically
595
+ url = f"{self.langgraph_api_url}/runs"
596
+
597
+ logger.info(f"Invoking LangGraph agent {agent_id} for user {user_id}")
598
+
599
+ async with httpx.AsyncClient() as client:
600
+ try:
601
+ response = await client.post(
602
+ url,
603
+ json=payload,
604
+ headers=headers,
605
+ timeout=30.0,
606
+ )
607
+ response.raise_for_status()
608
+
609
+ result = response.json()
610
+ logger.info(f"Successfully invoked agent {agent_id}")
611
+ return result
612
+
613
+ except httpx.HTTPStatusError as e:
614
+ logger.error(f"HTTP error invoking agent: {e.response.status_code} - {e.response.text}")
615
+ raise
616
+ except Exception as e:
617
+ logger.error(f"Error invoking agent {agent_id}: {e}")
618
+ raise
619
+
620
+ async def _get_authenticated_user(self, trigger: TriggerTemplate, user_id: str) -> UserAuthInfo:
621
+ """Get authenticated user with OAuth tokens for the trigger's required providers."""
622
+ providers = {}
623
+
624
+ # Get tokens for each required OAuth provider
625
+ for provider, scopes in trigger.oauth_providers.items():
626
+ try:
627
+ auth_result = await self.langchain_auth_client.authenticate(
628
+ provider=provider,
629
+ scopes=scopes,
630
+ user_id=user_id
631
+ )
632
+
633
+ # Debug logging
634
+ logger.info(f"Auth result for {provider}: {vars(auth_result) if hasattr(auth_result, '__dict__') else 'Not available'}")
635
+
636
+ if hasattr(auth_result, 'token') and auth_result.token:
637
+ providers[provider] = ProviderAuthInfo(token=auth_result.token)
638
+ logger.debug(f"Successfully got {provider} token for user {user_id}")
639
+ elif hasattr(auth_result, 'auth_required') and auth_result.auth_required:
640
+ logger.info(f"User {user_id} needs to authenticate for {provider}")
641
+ providers[provider] = ProviderAuthInfo(
642
+ auth_required=True,
643
+ auth_url=getattr(auth_result, 'auth_url', None),
644
+ auth_id=getattr(auth_result, 'auth_id', None)
645
+ )
646
+ else:
647
+ logger.warning(f"No token returned for {provider} provider")
648
+
649
+ except Exception as e:
650
+ logger.error(f"Failed to get {provider} token: {e}")
651
+ # Continue with other providers
652
+
653
+ return UserAuthInfo(user_id=user_id, providers=providers)
654
+
655
+ def get_app(self) -> FastAPI:
656
+ """Get the FastAPI app instance."""
657
+ return self.app