+
+

+
+
Welcome to Kortix Suna!
+
+
Hi {user_name},
+
+
Welcome to Kortix Suna — we're excited to have you on board!
+
+
To get started, we'd like to get to know you better: fill out this short form!
+
+
To celebrate your arrival, here's a 15% discount to try out the best version of Suna (1 month):
+
+
🎁 Use code WELCOME15 at checkout.
+
+
Let us know if you need help getting started or have questions — we're always here, and join our Discord community.
+
+
For your business: if you want to automate manual and ordinary tasks for your company, book a call with us here
+
+
Thanks again, and welcome to the Suna community 🌞
+
+
— The Suna Team
+
+
Go to the platform
+
+
+"""
+
+ def _get_welcome_email_text(self, user_name: str) -> str:
+ return f"""Hi {user_name},
+
+Welcome to Suna — we're excited to have you on board!
+
+To get started, we'd like to get to know you better: fill out this short form!
+https://docs.google.com/forms/d/e/1FAIpQLSef1EHuqmIh_iQz-kwhjnzSC3Ml-V_5wIySDpMoMU9W_j24JQ/viewform
+
+To celebrate your arrival, here's a 15% discount to try out the best version of Suna (1 month):
+🎁 Use code WELCOME15 at checkout.
+
+Let us know if you need help getting started or have questions — we're always here, and join our Discord community: https://discord.com/invite/FjD644cfcs
+
+For your business: if you want to automate manual and ordinary tasks for your company, book a call with us here: https://cal.com/team/kortix/enterprise-demo
+
+Thanks again, and welcome to the Suna community 🌞
+
+— The Suna Team
+
+Go to the platform: https://www.suna.so/
+
+---
+© 2024 Suna. All rights reserved.
+You received this email because you signed up for a Suna account."""
+
+email_service = EmailService()
diff --git a/app/services/email_api.py b/app/services/email_api.py
new file mode 100644
index 0000000..9834c7b
--- /dev/null
+++ b/app/services/email_api.py
@@ -0,0 +1,70 @@
+from fastapi import APIRouter, HTTPException, Depends
+from pydantic import BaseModel, EmailStr
+from typing import Optional
+import asyncio
+from services.email import email_service
+from utils.logger import logger
+
+router = APIRouter()
+
+class SendWelcomeEmailRequest(BaseModel):
+ email: EmailStr
+ name: Optional[str] = None
+
+class EmailResponse(BaseModel):
+ success: bool
+ message: str
+
+@router.post("/send-welcome-email", response_model=EmailResponse)
+async def send_welcome_email(request: SendWelcomeEmailRequest):
+ try:
+ logger.info(f"Sending welcome email to {request.email}")
+ success = email_service.send_welcome_email(
+ user_email=request.email,
+ user_name=request.name
+ )
+
+ if success:
+ return EmailResponse(
+ success=True,
+ message="Welcome email sent successfully"
+ )
+ else:
+ return EmailResponse(
+ success=False,
+ message="Failed to send welcome email"
+ )
+
+ except Exception as e:
+ logger.error(f"Error sending welcome email to {request.email}: {str(e)}")
+ raise HTTPException(
+ status_code=500,
+ detail="Internal server error while sending email"
+ )
+
+@router.post("/send-welcome-email-background", response_model=EmailResponse)
+async def send_welcome_email_background(request: SendWelcomeEmailRequest):
+ try:
+ logger.info(f"Queuing welcome email for {request.email}")
+
+ def send_email():
+ return email_service.send_welcome_email(
+ user_email=request.email,
+ user_name=request.name
+ )
+
+ import concurrent.futures
+ with concurrent.futures.ThreadPoolExecutor() as executor:
+ future = executor.submit(send_email)
+
+ return EmailResponse(
+ success=True,
+ message="Welcome email queued for sending"
+ )
+
+ except Exception as e:
+ logger.error(f"Error queuing welcome email for {request.email}: {str(e)}")
+ raise HTTPException(
+ status_code=500,
+ detail="Internal server error while queuing email"
+ )
diff --git a/app/services/langfuse.py b/app/services/langfuse.py
new file mode 100644
index 0000000..cf624bf
--- /dev/null
+++ b/app/services/langfuse.py
@@ -0,0 +1,12 @@
+import os
+from langfuse import Langfuse
+
+public_key = os.getenv("LANGFUSE_PUBLIC_KEY")
+secret_key = os.getenv("LANGFUSE_SECRET_KEY")
+host = os.getenv("LANGFUSE_HOST", "https://cloud.langfuse.com")
+
+enabled = False
+if public_key and secret_key:
+ enabled = True
+
+langfuse = Langfuse(enabled=enabled)
diff --git a/app/services/llm.py b/app/services/llm.py
new file mode 100644
index 0000000..c749170
--- /dev/null
+++ b/app/services/llm.py
@@ -0,0 +1,411 @@
+"""
+LLM API interface for making calls to various language models.
+
+This module provides a unified interface for making API calls to different LLM providers
+(OpenAI, Anthropic, Groq, etc.) using LiteLLM. It includes support for:
+- Streaming responses
+- Tool calls and function calling
+- Retry logic with exponential backoff
+- Model-specific configurations
+- Comprehensive error handling and logging
+"""
+
+from typing import Union, Dict, Any, Optional, AsyncGenerator, List
+import os
+import json
+import asyncio
+from openai import OpenAIError
+import litellm
+from utils.logger import logger
+from utils.config import config
+
+# litellm.set_verbose=True
+litellm.modify_params=True
+
+# Constants
+MAX_RETRIES = 2
+RATE_LIMIT_DELAY = 30
+RETRY_DELAY = 0.1
+
+class LLMError(Exception):
+ """Base exception for LLM-related errors."""
+ pass
+
+class LLMRetryError(LLMError):
+ """Exception raised when retries are exhausted."""
+ pass
+
+def setup_api_keys() -> None:
+ """Set up API keys from environment variables."""
+ providers = ['OPENAI', 'ANTHROPIC', 'GROQ', 'OPENROUTER']
+ for provider in providers:
+ key = getattr(config, f'{provider}_API_KEY')
+ if key:
+ logger.debug(f"API key set for provider: {provider}")
+ else:
+ logger.warning(f"No API key found for provider: {provider}")
+
+ # Set up OpenRouter API base if not already set
+ if config.OPENROUTER_API_KEY and config.OPENROUTER_API_BASE:
+ os.environ['OPENROUTER_API_BASE'] = config.OPENROUTER_API_BASE
+ logger.debug(f"Set OPENROUTER_API_BASE to {config.OPENROUTER_API_BASE}")
+
+ # Set up AWS Bedrock credentials
+ aws_access_key = config.AWS_ACCESS_KEY_ID
+ aws_secret_key = config.AWS_SECRET_ACCESS_KEY
+ aws_region = config.AWS_REGION_NAME
+
+ if aws_access_key and aws_secret_key and aws_region:
+ logger.debug(f"AWS credentials set for Bedrock in region: {aws_region}")
+ # Configure LiteLLM to use AWS credentials
+ os.environ['AWS_ACCESS_KEY_ID'] = aws_access_key
+ os.environ['AWS_SECRET_ACCESS_KEY'] = aws_secret_key
+ os.environ['AWS_REGION_NAME'] = aws_region
+ else:
+ logger.warning(f"Missing AWS credentials for Bedrock integration - access_key: {bool(aws_access_key)}, secret_key: {bool(aws_secret_key)}, region: {aws_region}")
+
+async def handle_error(error: Exception, attempt: int, max_attempts: int) -> None:
+ """Handle API errors with appropriate delays and logging."""
+ delay = RATE_LIMIT_DELAY if isinstance(error, litellm.exceptions.RateLimitError) else RETRY_DELAY
+ logger.warning(f"Error on attempt {attempt + 1}/{max_attempts}: {str(error)}")
+ logger.debug(f"Waiting {delay} seconds before retry...")
+ await asyncio.sleep(delay)
+
+def prepare_params(
+ messages: List[Dict[str, Any]],
+ model_name: str,
+ temperature: float = 0,
+ max_tokens: Optional[int] = None,
+ response_format: Optional[Any] = None,
+ tools: Optional[List[Dict[str, Any]]] = None,
+ tool_choice: str = "auto",
+ api_key: Optional[str] = None,
+ api_base: Optional[str] = None,
+ stream: bool = False,
+ top_p: Optional[float] = None,
+ model_id: Optional[str] = None,
+ enable_thinking: Optional[bool] = False,
+ reasoning_effort: Optional[str] = 'low'
+) -> Dict[str, Any]:
+ """Prepare parameters for the API call."""
+ params = {
+ "model": model_name,
+ "messages": messages,
+ "temperature": temperature,
+ "response_format": response_format,
+ "top_p": top_p,
+ "stream": stream,
+ }
+
+ if api_key:
+ params["api_key"] = api_key
+ if api_base:
+ params["api_base"] = api_base
+ if model_id:
+ params["model_id"] = model_id
+
+ # Handle token limits
+ if max_tokens is not None:
+ # For Claude 3.7 in Bedrock, do not set max_tokens or max_tokens_to_sample
+ # as it causes errors with inference profiles
+ if model_name.startswith("bedrock/") and "claude-3-7" in model_name:
+ logger.debug(f"Skipping max_tokens for Claude 3.7 model: {model_name}")
+ # Do not add any max_tokens parameter for Claude 3.7
+ else:
+ param_name = "max_completion_tokens" if 'o1' in model_name else "max_tokens"
+ params[param_name] = max_tokens
+
+ # Add tools if provided
+ if tools:
+ params.update({
+ "tools": tools,
+ "tool_choice": tool_choice
+ })
+ logger.debug(f"Added {len(tools)} tools to API parameters")
+
+ # # Add Claude-specific headers
+ if "claude" in model_name.lower() or "anthropic" in model_name.lower():
+ params["extra_headers"] = {
+ # "anthropic-beta": "max-tokens-3-5-sonnet-2024-07-15"
+ "anthropic-beta": "output-128k-2025-02-19"
+ }
+ params["fallbacks"] = [{
+ "model": "openrouter/anthropic/claude-sonnet-4",
+ "messages": messages,
+ }]
+ logger.debug("Added Claude-specific headers")
+
+ # Add OpenRouter-specific parameters
+ if model_name.startswith("openrouter/"):
+ logger.debug(f"Preparing OpenRouter parameters for model: {model_name}")
+
+ # Add optional site URL and app name from config
+ site_url = config.OR_SITE_URL
+ app_name = config.OR_APP_NAME
+ if site_url or app_name:
+ extra_headers = params.get("extra_headers", {})
+ if site_url:
+ extra_headers["HTTP-Referer"] = site_url
+ if app_name:
+ extra_headers["X-Title"] = app_name
+ params["extra_headers"] = extra_headers
+ logger.debug(f"Added OpenRouter site URL and app name to headers")
+
+ # Add Bedrock-specific parameters
+ if model_name.startswith("bedrock/"):
+ logger.debug(f"Preparing AWS Bedrock parameters for model: {model_name}")
+
+ if not model_id and "anthropic.claude-3-7-sonnet" in model_name:
+ params["model_id"] = "arn:aws:bedrock:us-west-2:935064898258:inference-profile/us.anthropic.claude-3-7-sonnet-20250219-v1:0"
+ logger.debug(f"Auto-set model_id for Claude 3.7 Sonnet: {params['model_id']}")
+
+ # Apply Anthropic prompt caching (minimal implementation)
+ # Check model name *after* potential modifications (like adding bedrock/ prefix)
+ effective_model_name = params.get("model", model_name) # Use model from params if set, else original
+ if "claude" in effective_model_name.lower() or "anthropic" in effective_model_name.lower():
+ messages = params["messages"] # Direct reference, modification affects params
+
+ # Ensure messages is a list
+ if not isinstance(messages, list):
+ return params # Return early if messages format is unexpected
+
+ # 1. Process the first message if it's a system prompt with string content
+ if messages and messages[0].get("role") == "system":
+ content = messages[0].get("content")
+ if isinstance(content, str):
+ # Wrap the string content in the required list structure
+ messages[0]["content"] = [
+ {"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}
+ ]
+ elif isinstance(content, list):
+ # If content is already a list, check if the first text block needs cache_control
+ for item in content:
+ if isinstance(item, dict) and item.get("type") == "text":
+ if "cache_control" not in item:
+ item["cache_control"] = {"type": "ephemeral"}
+ break # Apply to the first text block only for system prompt
+
+ # 2. Find and process relevant user and assistant messages (limit to 4 max)
+ last_user_idx = -1
+ second_last_user_idx = -1
+ last_assistant_idx = -1
+
+ for i in range(len(messages) - 1, -1, -1):
+ role = messages[i].get("role")
+ if role == "user":
+ if last_user_idx == -1:
+ last_user_idx = i
+ elif second_last_user_idx == -1:
+ second_last_user_idx = i
+ elif role == "assistant":
+ if last_assistant_idx == -1:
+ last_assistant_idx = i
+
+ # Stop searching if we've found all needed messages (system, last user, second last user, last assistant)
+ found_count = sum(idx != -1 for idx in [last_user_idx, second_last_user_idx, last_assistant_idx])
+ if found_count >= 3:
+ break
+
+ # Helper function to apply cache control
+ def apply_cache_control(message_idx: int, message_role: str):
+ if message_idx == -1:
+ return
+
+ message = messages[message_idx]
+ content = message.get("content")
+
+ if isinstance(content, str):
+ message["content"] = [
+ {"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}
+ ]
+ elif isinstance(content, list):
+ for item in content:
+ if isinstance(item, dict) and item.get("type") == "text":
+ if "cache_control" not in item:
+ item["cache_control"] = {"type": "ephemeral"}
+
+ # Apply cache control to the identified messages (max 4: system, last user, second last user, last assistant)
+ # System message is always at index 0 if present
+ apply_cache_control(0, "system")
+ apply_cache_control(last_user_idx, "last user")
+ apply_cache_control(second_last_user_idx, "second last user")
+ apply_cache_control(last_assistant_idx, "last assistant")
+
+ # Add reasoning_effort for Anthropic models if enabled
+ use_thinking = enable_thinking if enable_thinking is not None else False
+ is_anthropic = "anthropic" in effective_model_name.lower() or "claude" in effective_model_name.lower()
+
+ if is_anthropic and use_thinking:
+ effort_level = reasoning_effort if reasoning_effort else 'low'
+ params["reasoning_effort"] = effort_level
+ params["temperature"] = 1.0 # Required by Anthropic when reasoning_effort is used
+ logger.info(f"Anthropic thinking enabled with reasoning_effort='{effort_level}'")
+
+ return params
+
+async def make_llm_api_call(
+ messages: List[Dict[str, Any]],
+ model_name: str,
+ response_format: Optional[Any] = None,
+ temperature: float = 0,
+ max_tokens: Optional[int] = None,
+ tools: Optional[List[Dict[str, Any]]] = None,
+ tool_choice: str = "auto",
+ api_key: Optional[str] = None,
+ api_base: Optional[str] = None,
+ stream: bool = False,
+ top_p: Optional[float] = None,
+ model_id: Optional[str] = None,
+ enable_thinking: Optional[bool] = False,
+ reasoning_effort: Optional[str] = 'low'
+) -> Union[Dict[str, Any], AsyncGenerator]:
+ """
+ Make an API call to a language model using LiteLLM.
+
+ Args:
+ messages: List of message dictionaries for the conversation
+ model_name: Name of the model to use (e.g., "gpt-4", "claude-3", "openrouter/openai/gpt-4", "bedrock/anthropic.claude-3-sonnet-20240229-v1:0")
+ response_format: Desired format for the response
+ temperature: Sampling temperature (0-1)
+ max_tokens: Maximum tokens in the response
+ tools: List of tool definitions for function calling
+ tool_choice: How to select tools ("auto" or "none")
+ api_key: Override default API key
+ api_base: Override default API base URL
+ stream: Whether to stream the response
+ top_p: Top-p sampling parameter
+ model_id: Optional ARN for Bedrock inference profiles
+ enable_thinking: Whether to enable thinking
+ reasoning_effort: Level of reasoning effort
+
+ Returns:
+ Union[Dict[str, Any], AsyncGenerator]: API response or stream
+
+ Raises:
+ LLMRetryError: If API call fails after retries
+ LLMError: For other API-related errors
+ """
+ # debug