mirror of https://github.com/kortix-ai/suna.git
Compare commits
18 Commits
a352d35efa
...
d904e03816
Author | SHA1 | Date |
---|---|---|
|
d904e03816 | |
|
be39b91a96 | |
|
7a57ee210a | |
|
b22eb88cac | |
|
7977415b15 | |
|
7ff3ed9193 | |
|
008c96b153 | |
|
d7f4ade6f7 | |
|
48524afd51 | |
|
6ead569007 | |
|
eda0cee931 | |
|
059270ce6b | |
|
551430ffd6 | |
|
d4e2665e6b | |
|
e985cbdc2b | |
|
7bffa72056 | |
|
c6b9c7c427 | |
|
c1514d56a1 |
|
@ -1,9 +1,10 @@
|
|||
GROQ_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
TAVILY_API_KEY=
|
||||
# Environment Mode
|
||||
# Valid values: local, staging, production
|
||||
ENV_MODE=local
|
||||
|
||||
#DATABASE
|
||||
SUPABASE_URL=
|
||||
SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
@ -13,13 +14,23 @@ REDIS_PORT=
|
|||
REDIS_PASSWORD=
|
||||
REDIS_SSL=
|
||||
|
||||
# AWS Bedrock:
|
||||
# LLM Providers:
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_REGION_NAME=
|
||||
|
||||
# Sandbox container provider:
|
||||
GROQ_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# DATA APIS
|
||||
RAPID_API_KEY=
|
||||
|
||||
# WEB SEARCH & CRAWL
|
||||
TAVILY_API_KEY=
|
||||
|
||||
# Sandbox container provider:
|
||||
DAYTONA_API_KEY=
|
||||
DAYTONA_SERVER_URL=
|
||||
DAYTONA_TARGET=
|
1103
backend/agent/api.py
1103
backend/agent/api.py
File diff suppressed because it is too large
Load Diff
|
@ -10,6 +10,7 @@ from agent.tools.sb_deploy_tool import SandboxDeployTool
|
|||
from agent.tools.sb_expose_tool import SandboxExposeTool
|
||||
from agent.tools.web_search_tool import WebSearchTool
|
||||
from dotenv import load_dotenv
|
||||
from utils.config import config
|
||||
|
||||
from agentpress.thread_manager import ThreadManager
|
||||
from agentpress.response_processor import ProcessorConfig
|
||||
|
@ -65,12 +66,12 @@ async def run_agent(
|
|||
thread_manager.add_tool(SandboxExposeTool, project_id=project_id, thread_manager=thread_manager)
|
||||
thread_manager.add_tool(MessageTool) # we are just doing this via prompt as there is no need to call it as a tool
|
||||
|
||||
if os.getenv("TAVILY_API_KEY"):
|
||||
# Add more tools if API keys are available
|
||||
if config.TAVILY_API_KEY:
|
||||
thread_manager.add_tool(WebSearchTool)
|
||||
else:
|
||||
logger.warning("TAVILY_API_KEY not found, WebSearchTool will not be available.")
|
||||
|
||||
if os.getenv("RAPID_API_KEY"):
|
||||
# Add data providers tool if RapidAPI key is available
|
||||
if config.RAPID_API_KEY:
|
||||
thread_manager.add_tool(DataProvidersTool)
|
||||
|
||||
system_message = { "role": "system", "content": get_system_prompt() }
|
||||
|
|
|
@ -5,6 +5,7 @@ from datetime import datetime
|
|||
import os
|
||||
from dotenv import load_dotenv
|
||||
from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema
|
||||
from utils.config import config
|
||||
import json
|
||||
|
||||
# TODO: add subpages, etc... in filters as sometimes its necessary
|
||||
|
@ -17,9 +18,9 @@ class WebSearchTool(Tool):
|
|||
# Load environment variables
|
||||
load_dotenv()
|
||||
# Use the provided API key or get it from environment variables
|
||||
self.api_key = api_key or os.getenv("TAVILY_API_KEY")
|
||||
self.api_key = api_key or config.TAVILY_API_KEY
|
||||
if not self.api_key:
|
||||
raise ValueError("TAVILY_API_KEY not found in environment variables")
|
||||
raise ValueError("TAVILY_API_KEY not found in configuration")
|
||||
|
||||
# Tavily asynchronous search client
|
||||
self.tavily_client = AsyncTavilyClient(api_key=self.api_key)
|
||||
|
@ -247,9 +248,9 @@ class WebSearchTool(Tool):
|
|||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
print(f"--- Raw Tavily Response ---")
|
||||
print(data)
|
||||
print(f"--------------------------")
|
||||
# print(f"--- Raw Tavily Response ---")
|
||||
# print(data)
|
||||
# print(f"--------------------------")
|
||||
|
||||
# Normalise Tavily extract output to a list of dicts
|
||||
extracted = []
|
||||
|
|
|
@ -6,6 +6,7 @@ from agentpress.thread_manager import ThreadManager
|
|||
from services.supabase import DBConnection
|
||||
from datetime import datetime, timezone
|
||||
from dotenv import load_dotenv
|
||||
from utils.config import config
|
||||
import asyncio
|
||||
from utils.logger import logger
|
||||
import uuid
|
||||
|
@ -16,7 +17,7 @@ from collections import OrderedDict
|
|||
from agent import api as agent_api
|
||||
from sandbox import api as sandbox_api
|
||||
|
||||
# Load environment variables
|
||||
# Load environment variables (these will be available through config)
|
||||
load_dotenv()
|
||||
|
||||
# Initialize managers
|
||||
|
@ -32,7 +33,7 @@ MAX_CONCURRENT_IPS = 25
|
|||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
global thread_manager
|
||||
logger.info(f"Starting up FastAPI application with instance ID: {instance_id}")
|
||||
logger.info(f"Starting up FastAPI application with instance ID: {instance_id} in {config.ENV_MODE.value} mode")
|
||||
await db.initialize()
|
||||
thread_manager = ThreadManager()
|
||||
|
||||
|
@ -46,9 +47,9 @@ async def lifespan(app: FastAPI):
|
|||
# Initialize the sandbox API with shared resources
|
||||
sandbox_api.initialize(db)
|
||||
|
||||
# Initialize Redis before restoring agent runs
|
||||
from services import redis
|
||||
await redis.initialize_async()
|
||||
# Redis is no longer needed for a single-server setup
|
||||
# from services import redis
|
||||
# await redis.initialize_async()
|
||||
|
||||
asyncio.create_task(agent_api.restore_running_agent_runs())
|
||||
|
||||
|
|
|
@ -13,6 +13,9 @@ COPY . .
|
|||
# Set environment variable
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# Environment mode (local, staging, production)
|
||||
ENV ENV_MODE="production"
|
||||
|
||||
# Default environment variables
|
||||
ENV DAYTONA_API_KEY=""
|
||||
ENV DAYTONA_SERVER_URL=""
|
||||
|
|
|
@ -21,3 +21,6 @@ primary_region = 'bos'
|
|||
memory = '16gb'
|
||||
cpu_kind = 'performance'
|
||||
cpus = 8
|
||||
|
||||
[env]
|
||||
ENV_MODE = "production"
|
||||
|
|
|
@ -21,3 +21,6 @@ primary_region = 'cdg'
|
|||
memory = '1gb'
|
||||
cpu_kind = 'shared'
|
||||
cpus = 1
|
||||
|
||||
[env]
|
||||
ENV_MODE = "staging"
|
||||
|
|
|
@ -74,7 +74,6 @@ async def verify_sandbox_access(client, sandbox_id: str, user_id: Optional[str]
|
|||
async def get_sandbox_by_id_safely(client, sandbox_id: str):
|
||||
"""
|
||||
Safely retrieve a sandbox object by its ID, using the project that owns it.
|
||||
This prevents race conditions by leveraging the distributed locking mechanism.
|
||||
|
||||
Args:
|
||||
client: The Supabase client
|
||||
|
@ -97,7 +96,7 @@ async def get_sandbox_by_id_safely(client, sandbox_id: str):
|
|||
logger.debug(f"Found project {project_id} for sandbox {sandbox_id}")
|
||||
|
||||
try:
|
||||
# Use the race-condition-safe function to get the sandbox
|
||||
# Get the sandbox
|
||||
sandbox, retrieved_sandbox_id, sandbox_pass = await get_or_create_project_sandbox(client, project_id)
|
||||
|
||||
# Verify we got the right sandbox
|
||||
|
@ -259,7 +258,6 @@ async def ensure_project_sandbox_active(
|
|||
"""
|
||||
Ensure that a project's sandbox is active and running.
|
||||
Checks the sandbox status and starts it if it's not running.
|
||||
Uses distributed locking to prevent race conditions.
|
||||
"""
|
||||
client = await db.client
|
||||
|
||||
|
@ -286,8 +284,8 @@ async def ensure_project_sandbox_active(
|
|||
raise HTTPException(status_code=403, detail="Not authorized to access this project")
|
||||
|
||||
try:
|
||||
# Use the safer function that handles race conditions with distributed locking
|
||||
logger.info(f"Ensuring sandbox is active for project {project_id} using distributed locking")
|
||||
# Get or create the sandbox
|
||||
logger.info(f"Ensuring sandbox is active for project {project_id}")
|
||||
sandbox, sandbox_id, sandbox_pass = await get_or_create_project_sandbox(client, project_id)
|
||||
|
||||
logger.info(f"Successfully ensured sandbox {sandbox_id} is active for project {project_id}")
|
||||
|
|
|
@ -7,34 +7,35 @@ from dotenv import load_dotenv
|
|||
|
||||
from agentpress.tool import Tool
|
||||
from utils.logger import logger
|
||||
from utils.config import config
|
||||
from utils.files_utils import clean_path
|
||||
from agentpress.thread_manager import ThreadManager
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger.debug("Initializing Daytona sandbox configuration")
|
||||
config = DaytonaConfig(
|
||||
api_key=os.getenv("DAYTONA_API_KEY"),
|
||||
server_url=os.getenv("DAYTONA_SERVER_URL"),
|
||||
target=os.getenv("DAYTONA_TARGET")
|
||||
daytona_config = DaytonaConfig(
|
||||
api_key=config.DAYTONA_API_KEY,
|
||||
server_url=config.DAYTONA_SERVER_URL,
|
||||
target=config.DAYTONA_TARGET
|
||||
)
|
||||
|
||||
if config.api_key:
|
||||
if daytona_config.api_key:
|
||||
logger.debug("Daytona API key configured successfully")
|
||||
else:
|
||||
logger.warning("No Daytona API key found in environment variables")
|
||||
|
||||
if config.server_url:
|
||||
logger.debug(f"Daytona server URL set to: {config.server_url}")
|
||||
if daytona_config.server_url:
|
||||
logger.debug(f"Daytona server URL set to: {daytona_config.server_url}")
|
||||
else:
|
||||
logger.warning("No Daytona server URL found in environment variables")
|
||||
|
||||
if config.target:
|
||||
logger.debug(f"Daytona target set to: {config.target}")
|
||||
if daytona_config.target:
|
||||
logger.debug(f"Daytona target set to: {daytona_config.target}")
|
||||
else:
|
||||
logger.warning("No Daytona target found in environment variables")
|
||||
|
||||
daytona = Daytona(config)
|
||||
daytona = Daytona(daytona_config)
|
||||
logger.debug("Daytona client initialized")
|
||||
|
||||
async def get_or_start_sandbox(sandbox_id: str):
|
||||
|
|
|
@ -17,6 +17,7 @@ import asyncio
|
|||
from openai import OpenAIError
|
||||
import litellm
|
||||
from utils.logger import logger
|
||||
from utils.config import config
|
||||
from datetime import datetime
|
||||
import traceback
|
||||
|
||||
|
@ -40,21 +41,21 @@ def setup_api_keys() -> None:
|
|||
"""Set up API keys from environment variables."""
|
||||
providers = ['OPENAI', 'ANTHROPIC', 'GROQ', 'OPENROUTER']
|
||||
for provider in providers:
|
||||
key = os.environ.get(f'{provider}_API_KEY')
|
||||
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 os.environ.get('OPENROUTER_API_KEY') and not os.environ.get('OPENROUTER_API_BASE'):
|
||||
os.environ['OPENROUTER_API_BASE'] = 'https://openrouter.ai/api/v1'
|
||||
logger.debug("Set default OPENROUTER_API_BASE to https://openrouter.ai/api/v1")
|
||||
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 = os.environ.get('AWS_ACCESS_KEY_ID')
|
||||
aws_secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY')
|
||||
aws_region = os.environ.get('AWS_REGION_NAME')
|
||||
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}")
|
||||
|
@ -136,9 +137,9 @@ def prepare_params(
|
|||
if model_name.startswith("openrouter/"):
|
||||
logger.debug(f"Preparing OpenRouter parameters for model: {model_name}")
|
||||
|
||||
# Add optional site URL and app name if set in environment
|
||||
site_url = os.environ.get("OR_SITE_URL")
|
||||
app_name = os.environ.get("OR_APP_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:
|
||||
|
@ -160,12 +161,10 @@ def prepare_params(
|
|||
# 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():
|
||||
logger.debug("Applying minimal Anthropic prompt caching.")
|
||||
messages = params["messages"] # Direct reference, modification affects params
|
||||
|
||||
# Ensure messages is a list
|
||||
if not isinstance(messages, list):
|
||||
logger.warning(f"Messages is not a list ({type(messages)}), skipping Anthropic cache control.")
|
||||
return params # Return early if messages format is unexpected
|
||||
|
||||
# 1. Process the first message if it's a system prompt with string content
|
||||
|
@ -176,7 +175,6 @@ def prepare_params(
|
|||
messages[0]["content"] = [
|
||||
{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}
|
||||
]
|
||||
logger.debug("Applied cache_control to system message (converted from string).")
|
||||
elif isinstance(content, list):
|
||||
# If content is already a list, check if the first text block needs cache_control
|
||||
for item in content:
|
||||
|
@ -184,36 +182,49 @@ def prepare_params(
|
|||
if "cache_control" not in item:
|
||||
item["cache_control"] = {"type": "ephemeral"}
|
||||
break # Apply to the first text block only for system prompt
|
||||
else:
|
||||
logger.warning("System message content is not a string or list, skipping cache_control.")
|
||||
|
||||
# 2. Find and process the last user message
|
||||
# 2. Find and process relevant user and assistant messages
|
||||
last_user_idx = -1
|
||||
second_last_user_idx = -1
|
||||
last_assistant_idx = -1
|
||||
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
if messages[i].get("role") == "user":
|
||||
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
|
||||
if last_user_idx != -1 and second_last_user_idx != -1 and last_assistant_idx != -1:
|
||||
break
|
||||
|
||||
if last_user_idx != -1:
|
||||
last_user_message = messages[last_user_idx]
|
||||
content = last_user_message.get("content")
|
||||
# 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):
|
||||
# Wrap the string content in the required list structure
|
||||
last_user_message["content"] = [
|
||||
message["content"] = [
|
||||
{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}
|
||||
]
|
||||
logger.debug(f"Applied cache_control to last user message (string content, index {last_user_idx}).")
|
||||
elif isinstance(content, list):
|
||||
# Modify text blocks within the list directly
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
# Add cache_control if not already present
|
||||
if "cache_control" not in item:
|
||||
item["cache_control"] = {"type": "ephemeral"}
|
||||
|
||||
else:
|
||||
logger.warning(f"Last user message (index {last_user_idx}) content is not a string or list ({type(content)}), skipping cache_control.")
|
||||
# Apply cache control to the identified messages
|
||||
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
|
||||
|
@ -269,6 +280,7 @@ async def make_llm_api_call(
|
|||
LLMRetryError: If API call fails after retries
|
||||
LLMError: For other API-related errors
|
||||
"""
|
||||
# debug <timestamp>.json messages
|
||||
logger.debug(f"Making LLM API call to model: {model_name} (Thinking: {enable_thinking}, Effort: {reasoning_effort})")
|
||||
params = prepare_params(
|
||||
messages=messages,
|
||||
|
@ -286,7 +298,6 @@ async def make_llm_api_call(
|
|||
enable_thinking=enable_thinking,
|
||||
reasoning_effort=reasoning_effort
|
||||
)
|
||||
|
||||
last_error = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
|
|
|
@ -5,8 +5,10 @@ import asyncio
|
|||
import certifi
|
||||
import ssl
|
||||
from utils.logger import logger
|
||||
from utils.config import config
|
||||
import random
|
||||
from functools import wraps
|
||||
from typing import List # Added for type hinting
|
||||
|
||||
# Redis client
|
||||
client = None
|
||||
|
@ -70,17 +72,17 @@ def initialize():
|
|||
|
||||
# Create Redis client with more robust retry configuration
|
||||
client = redis.Redis(
|
||||
host=os.getenv('REDIS_HOST'),
|
||||
port=int(os.getenv('REDIS_PORT', '6379')),
|
||||
password=os.getenv('REDIS_PASSWORD'),
|
||||
ssl=os.getenv('REDIS_SSL', 'True').lower() == 'true',
|
||||
host=config.REDIS_HOST,
|
||||
port=config.REDIS_PORT,
|
||||
password=config.REDIS_PASSWORD,
|
||||
ssl=config.REDIS_SSL,
|
||||
ssl_ca_certs=certifi.where(),
|
||||
decode_responses=True,
|
||||
socket_timeout=5.0, # Socket timeout
|
||||
socket_timeout=5.0,
|
||||
socket_connect_timeout=5.0, # Connection timeout
|
||||
retry_on_timeout=True, # Auto-retry on timeout
|
||||
health_check_interval=30, # Check connection health every 30 seconds
|
||||
max_connections=100 # Limit connections to prevent overloading
|
||||
max_connections=200 # Limit connections to prevent overloading
|
||||
)
|
||||
|
||||
return client
|
||||
|
@ -141,29 +143,10 @@ async def get_client():
|
|||
|
||||
# Centralized Redis operation functions with built-in retry logic
|
||||
|
||||
async def set(key, value, ex=None, nx=None, xx=None):
|
||||
"""
|
||||
Set a Redis key with automatic retry.
|
||||
|
||||
Args:
|
||||
key: The key to set
|
||||
value: The value to set
|
||||
ex: Expiration time in seconds
|
||||
nx: Only set the key if it does not already exist
|
||||
xx: Only set the key if it already exists
|
||||
"""
|
||||
async def set(key, value, ex=None):
|
||||
"""Set a Redis key with automatic retry."""
|
||||
redis_client = await get_client()
|
||||
|
||||
# Build the kwargs based on which parameters are provided
|
||||
kwargs = {}
|
||||
if ex is not None:
|
||||
kwargs['ex'] = ex
|
||||
if nx is not None:
|
||||
kwargs['nx'] = nx
|
||||
if xx is not None:
|
||||
kwargs['xx'] = xx
|
||||
|
||||
return await with_retry(redis_client.set, key, value, **kwargs)
|
||||
return await with_retry(redis_client.set, key, value, ex=ex)
|
||||
|
||||
async def get(key, default=None):
|
||||
"""Get a Redis key with automatic retry."""
|
||||
|
@ -186,7 +169,31 @@ async def keys(pattern):
|
|||
redis_client = await get_client()
|
||||
return await with_retry(redis_client.keys, pattern)
|
||||
|
||||
async def rpush(key, *values):
|
||||
"""Append one or more values to a list with automatic retry."""
|
||||
redis_client = await get_client()
|
||||
return await with_retry(redis_client.rpush, key, *values)
|
||||
|
||||
async def lrange(key, start, end):
|
||||
"""Get a range of elements from a list with automatic retry."""
|
||||
redis_client = await get_client()
|
||||
# Note: lrange returns bytes if decode_responses=False, but we set it to True
|
||||
# Ensure the return type is List[str]
|
||||
result: List[str] = await with_retry(redis_client.lrange, key, start, end)
|
||||
return result
|
||||
|
||||
async def llen(key):
|
||||
"""Get the length of a list with automatic retry."""
|
||||
redis_client = await get_client()
|
||||
return await with_retry(redis_client.llen, key)
|
||||
|
||||
async def expire(key, time):
|
||||
"""Set a key's time to live in seconds with automatic retry."""
|
||||
redis_client = await get_client()
|
||||
return await with_retry(redis_client.expire, key, time)
|
||||
|
||||
async def create_pubsub():
|
||||
"""Create a Redis pubsub object."""
|
||||
redis_client = await get_client()
|
||||
# decode_responses=True in client init applies to pubsub messages too
|
||||
return redis_client.pubsub()
|
|
@ -6,6 +6,7 @@ import os
|
|||
from typing import Optional
|
||||
from supabase import create_async_client, AsyncClient
|
||||
from utils.logger import logger
|
||||
from utils.config import config
|
||||
|
||||
class DBConnection:
|
||||
"""Singleton database connection manager using Supabase."""
|
||||
|
@ -29,9 +30,9 @@ class DBConnection:
|
|||
return
|
||||
|
||||
try:
|
||||
supabase_url = os.getenv('SUPABASE_URL')
|
||||
supabase_url = config.SUPABASE_URL
|
||||
# Use service role key preferentially for backend operations
|
||||
supabase_key = os.getenv('SUPABASE_SERVICE_ROLE_KEY', os.getenv('SUPABASE_ANON_KEY'))
|
||||
supabase_key = config.SUPABASE_SERVICE_ROLE_KEY or config.SUPABASE_ANON_KEY
|
||||
|
||||
if not supabase_url or not supabase_key:
|
||||
logger.error("Missing required environment variables for Supabase connection")
|
||||
|
@ -40,7 +41,7 @@ class DBConnection:
|
|||
logger.debug("Initializing Supabase connection")
|
||||
self._client = await create_async_client(supabase_url, supabase_key)
|
||||
self._initialized = True
|
||||
key_type = "SERVICE_ROLE_KEY" if os.getenv('SUPABASE_SERVICE_ROLE_KEY') else "ANON_KEY"
|
||||
key_type = "SERVICE_ROLE_KEY" if config.SUPABASE_SERVICE_ROLE_KEY else "ANON_KEY"
|
||||
logger.debug(f"Database connection initialized with Supabase using {key_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"Database initialization error: {e}")
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
from datetime import datetime, timezone
|
||||
from typing import Dict, Optional, Tuple
|
||||
from utils.logger import logger
|
||||
from utils.config import config, EnvMode
|
||||
|
||||
# Define subscription tiers and their monthly limits (in minutes)
|
||||
SUBSCRIPTION_TIERS = {
|
||||
'price_1RGJ9GG6l1KZGqIroxSqgphC': {'name': 'free', 'minutes': 10},
|
||||
'price_1RGJ9LG6l1KZGqIrd9pwzeNW': {'name': 'base', 'minutes': 300}, # 100 hours = 6000 minutes
|
||||
'price_1RGJ9JG6l1KZGqIrVUU4ZRv6': {'name': 'extra', 'minutes': 2400} # 100 hours = 6000 minutes
|
||||
'price_1RGJ9GG6l1KZGqIroxSqgphC': {'name': 'free', 'minutes': 0},
|
||||
'price_1RGJ9LG6l1KZGqIrd9pwzeNW': {'name': 'base', 'minutes': 300},
|
||||
'price_1RGJ9JG6l1KZGqIrVUU4ZRv6': {'name': 'extra', 'minutes': 2400}
|
||||
}
|
||||
|
||||
async def get_account_subscription(client, account_id: str) -> Optional[Dict]:
|
||||
|
@ -72,6 +74,16 @@ async def check_billing_status(client, account_id: str) -> Tuple[bool, str, Opti
|
|||
Returns:
|
||||
Tuple[bool, str, Optional[Dict]]: (can_run, message, subscription_info)
|
||||
"""
|
||||
if config.ENV_MODE == EnvMode.LOCAL:
|
||||
logger.info("Running in local development mode - billing checks are disabled")
|
||||
return True, "Local development mode - billing disabled", {
|
||||
"price_id": "local_dev",
|
||||
"plan_name": "Local Development",
|
||||
"minutes_limit": "no limit"
|
||||
}
|
||||
|
||||
# For staging/production, check subscription status
|
||||
|
||||
# Get current subscription
|
||||
subscription = await get_account_subscription(client, account_id)
|
||||
|
||||
|
@ -82,6 +94,9 @@ async def check_billing_status(client, account_id: str) -> Tuple[bool, str, Opti
|
|||
'plan_name': 'Free'
|
||||
}
|
||||
|
||||
if not subscription or subscription.get('price_id') is None or subscription.get('price_id') == 'price_1RGJ9GG6l1KZGqIroxSqgphC':
|
||||
return False, "You are not subscribed to any plan. Please upgrade your plan to continue.", subscription
|
||||
|
||||
# Get tier info
|
||||
tier_info = SUBSCRIPTION_TIERS.get(subscription['price_id'])
|
||||
if not tier_info:
|
||||
|
|
|
@ -0,0 +1,168 @@
|
|||
"""
|
||||
Configuration management for AgentPress backend.
|
||||
|
||||
This module provides a centralized way to access configuration settings and
|
||||
environment variables across the application. It supports different environment
|
||||
modes (development, staging, production) and provides validation for required
|
||||
values.
|
||||
|
||||
Usage:
|
||||
from utils.config import config
|
||||
|
||||
# Access configuration values
|
||||
api_key = config.OPENAI_API_KEY
|
||||
env_mode = config.ENV_MODE
|
||||
"""
|
||||
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import Dict, Any, Optional, get_type_hints
|
||||
from dotenv import load_dotenv
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class EnvMode(Enum):
|
||||
"""Environment mode enumeration."""
|
||||
LOCAL = "local"
|
||||
STAGING = "staging"
|
||||
PRODUCTION = "production"
|
||||
|
||||
class Configuration:
|
||||
"""
|
||||
Centralized configuration for AgentPress backend.
|
||||
|
||||
This class loads environment variables and provides type checking and validation.
|
||||
Default values can be specified for optional configuration items.
|
||||
"""
|
||||
|
||||
# Environment mode
|
||||
ENV_MODE: EnvMode = EnvMode.LOCAL
|
||||
|
||||
# LLM API keys
|
||||
OPENAI_API_KEY: Optional[str] = None
|
||||
ANTHROPIC_API_KEY: Optional[str] = None
|
||||
GROQ_API_KEY: Optional[str] = None
|
||||
OPENROUTER_API_KEY: Optional[str] = None
|
||||
OPENROUTER_API_BASE: str = "https://openrouter.ai/api/v1"
|
||||
|
||||
# AWS Bedrock credentials
|
||||
AWS_ACCESS_KEY_ID: Optional[str] = None
|
||||
AWS_SECRET_ACCESS_KEY: Optional[str] = None
|
||||
AWS_REGION_NAME: Optional[str] = None
|
||||
|
||||
# Model configuration
|
||||
MODEL_TO_USE: str = "claude-3-haiku-20240307"
|
||||
|
||||
# Supabase configuration
|
||||
SUPABASE_URL: Optional[str] = None
|
||||
SUPABASE_ANON_KEY: Optional[str] = None
|
||||
SUPABASE_SERVICE_ROLE_KEY: Optional[str] = None
|
||||
|
||||
# Redis configuration
|
||||
REDIS_HOST: Optional[str] = None
|
||||
REDIS_PORT: int = 6379
|
||||
REDIS_PASSWORD: Optional[str] = None
|
||||
REDIS_SSL: bool = True
|
||||
|
||||
# Daytona sandbox configuration
|
||||
DAYTONA_API_KEY: Optional[str] = None
|
||||
DAYTONA_SERVER_URL: Optional[str] = None
|
||||
DAYTONA_TARGET: Optional[str] = None
|
||||
|
||||
# Search and other API keys
|
||||
TAVILY_API_KEY: Optional[str] = None
|
||||
RAPID_API_KEY: Optional[str] = None
|
||||
CLOUDFLARE_API_TOKEN: Optional[str] = None
|
||||
|
||||
# Stripe configuration
|
||||
STRIPE_SECRET_KEY: Optional[str] = None
|
||||
STRIPE_DEFAULT_PLAN_ID: Optional[str] = None
|
||||
STRIPE_DEFAULT_TRIAL_DAYS: int = 14
|
||||
|
||||
# Open Router configuration
|
||||
OR_SITE_URL: Optional[str] = None
|
||||
OR_APP_NAME: Optional[str] = "AgentPress"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize configuration by loading from environment variables."""
|
||||
# Load environment variables from .env file if it exists
|
||||
load_dotenv()
|
||||
|
||||
# Set environment mode first
|
||||
env_mode_str = os.getenv("ENV_MODE", EnvMode.LOCAL.value)
|
||||
try:
|
||||
self.ENV_MODE = EnvMode(env_mode_str.lower())
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid ENV_MODE: {env_mode_str}, defaulting to LOCAL")
|
||||
self.ENV_MODE = EnvMode.LOCAL
|
||||
|
||||
logger.info(f"Environment mode: {self.ENV_MODE.value}")
|
||||
|
||||
# Load configuration from environment variables
|
||||
self._load_from_env()
|
||||
|
||||
# Perform validation
|
||||
self._validate()
|
||||
|
||||
def _load_from_env(self):
|
||||
"""Load configuration values from environment variables."""
|
||||
for key, expected_type in get_type_hints(self.__class__).items():
|
||||
env_val = os.getenv(key)
|
||||
|
||||
if env_val is not None:
|
||||
# Convert environment variable to the expected type
|
||||
if expected_type == bool:
|
||||
# Handle boolean conversion
|
||||
setattr(self, key, env_val.lower() in ('true', 't', 'yes', 'y', '1'))
|
||||
elif expected_type == int:
|
||||
# Handle integer conversion
|
||||
try:
|
||||
setattr(self, key, int(env_val))
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid value for {key}: {env_val}, using default")
|
||||
elif expected_type == EnvMode:
|
||||
# Already handled for ENV_MODE
|
||||
pass
|
||||
else:
|
||||
# String or other type
|
||||
setattr(self, key, env_val)
|
||||
|
||||
def _validate(self):
|
||||
"""Validate configuration based on environment mode."""
|
||||
# Keys required in all environments
|
||||
required_keys = []
|
||||
|
||||
# Add keys required in non-local environments
|
||||
if self.ENV_MODE != EnvMode.LOCAL:
|
||||
required_keys.extend([
|
||||
"SUPABASE_URL",
|
||||
"SUPABASE_SERVICE_ROLE_KEY"
|
||||
])
|
||||
|
||||
# Additional keys required in production
|
||||
if self.ENV_MODE == EnvMode.PRODUCTION:
|
||||
required_keys.extend([
|
||||
"REDIS_HOST",
|
||||
"REDIS_PASSWORD"
|
||||
])
|
||||
|
||||
# Validate required keys
|
||||
for key in required_keys:
|
||||
if not getattr(self, key):
|
||||
logger.warning(f"Required configuration {key} is missing for {self.ENV_MODE.value} environment")
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a configuration value with an optional default."""
|
||||
return getattr(self, key, default)
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
"""Return configuration as a dictionary."""
|
||||
return {
|
||||
key: getattr(self, key)
|
||||
for key in get_type_hints(self.__class__).keys()
|
||||
if not key.startswith('_')
|
||||
}
|
||||
|
||||
# Create a singleton instance
|
||||
config = Configuration()
|
|
@ -243,7 +243,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
const messagesLoadedRef = useRef(false);
|
||||
const agentRunsCheckedRef = useRef(false);
|
||||
const previousAgentStatus = useRef<typeof agentStatus>('idle');
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null); // POLLING FOR MESSAGES
|
||||
|
||||
const handleProjectRenamed = useCallback((newName: string) => {
|
||||
setProjectName(newName);
|
||||
|
@ -579,7 +579,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
};
|
||||
}, [threadId]);
|
||||
|
||||
const handleSubmitMessage = useCallback(async (message: string) => {
|
||||
const handleSubmitMessage = useCallback(async (message: string, options?: { model_name?: string; enable_thinking?: boolean }) => {
|
||||
if (!message.trim()) return;
|
||||
setIsSending(true);
|
||||
|
||||
|
@ -601,7 +601,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
try {
|
||||
const results = await Promise.allSettled([
|
||||
addUserMessage(threadId, message),
|
||||
startAgent(threadId)
|
||||
startAgent(threadId, options)
|
||||
]);
|
||||
|
||||
if (results[0].status === 'rejected') {
|
||||
|
@ -969,6 +969,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
}
|
||||
}, [projectName]);
|
||||
|
||||
// POLLING FOR MESSAGES
|
||||
// Set up polling for messages
|
||||
useEffect(() => {
|
||||
// Function to fetch messages
|
||||
|
@ -1024,6 +1025,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
}
|
||||
};
|
||||
}, [threadId, userHasScrolled, initialLoadCompleted]);
|
||||
// POLLING FOR MESSAGES
|
||||
|
||||
// Add another useEffect to ensure messages are refreshed when agent status changes to idle
|
||||
useEffect(() => {
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { MaintenanceAlert } from "@/components/maintenance-alert"
|
||||
import { useAccounts } from "@/hooks/use-accounts"
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode
|
||||
|
@ -16,6 +17,8 @@ export default function DashboardLayout({
|
|||
children,
|
||||
}: DashboardLayoutProps) {
|
||||
const [showMaintenanceAlert, setShowMaintenanceAlert] = useState(false)
|
||||
const { data: accounts } = useAccounts()
|
||||
const personalAccount = accounts?.find(account => account.personal_account)
|
||||
|
||||
useEffect(() => {
|
||||
// Show the maintenance alert when component mounts
|
||||
|
@ -35,6 +38,7 @@ export default function DashboardLayout({
|
|||
open={showMaintenanceAlert}
|
||||
onOpenChange={setShowMaintenanceAlert}
|
||||
closeable={true}
|
||||
accountId={personalAccount?.account_id}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
)
|
||||
|
|
|
@ -1,94 +1,149 @@
|
|||
"use client"
|
||||
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
|
||||
import { Clock, Github, X } from "lucide-react"
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
|
||||
import { AlertCircle, X, Zap, Github } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Portal } from "@/components/ui/portal"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { setupNewSubscription } from "@/lib/actions/billing"
|
||||
import { SubmitButton } from "@/components/ui/submit-button"
|
||||
import { siteConfig } from "@/lib/home"
|
||||
|
||||
interface MaintenanceAlertProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
closeable?: boolean
|
||||
accountId?: string | null | undefined
|
||||
}
|
||||
|
||||
export function MaintenanceAlert({ open, onOpenChange, closeable = true }: MaintenanceAlertProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={closeable ? onOpenChange : undefined}>
|
||||
<AlertDialogContent className="max-w-2xl w-[90vw] p-0 border-0 shadow-lg overflow-hidden rounded-2xl z-[9999]">
|
||||
<motion.div
|
||||
className="relative"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
||||
>
|
||||
{/* Background pattern */}
|
||||
<div className="absolute inset-0 bg-accent/20 opacity-20">
|
||||
<div className="absolute inset-0 bg-grid-white/10 [mask-image:radial-gradient(white,transparent_85%)]" />
|
||||
</div>
|
||||
export function MaintenanceAlert({ open, onOpenChange, closeable = true, accountId }: MaintenanceAlertProps) {
|
||||
const returnUrl = typeof window !== 'undefined' ? window.location.href : '';
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Filter plans to show only Pro and Enterprise
|
||||
const premiumPlans = siteConfig.cloudPricingItems.filter(plan =>
|
||||
plan.name === 'Pro' || plan.name === 'Enterprise'
|
||||
);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center overflow-y-auto py-4"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={closeable ? () => onOpenChange(false) : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
"relative bg-background rounded-lg shadow-xl w-full max-w-md mx-3"
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="free-tier-modal-title"
|
||||
>
|
||||
<div className="p-4">
|
||||
{/* Close button */}
|
||||
{closeable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-4 top-4 z-20 rounded-full hover:bg-background/80"
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute top-2 right-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AlertDialogHeader className="gap-6 px-8 pt-10 pb-6 relative z-10">
|
||||
<motion.div
|
||||
className="flex items-center justify-center"
|
||||
initial={{ y: -10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-gradient-to-t from-primary/20 to-secondary/10 backdrop-blur-md">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-gradient-to-t from-primary to-primary/80 shadow-md">
|
||||
<Clock className="h-6 w-6 text-white" />
|
||||
{/* Header */}
|
||||
<div className="text-center mb-4">
|
||||
<div className="inline-flex items-center justify-center p-1.5 bg-primary/10 rounded-full mb-2">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<h2 id="free-tier-modal-title" className="text-lg font-medium tracking-tight mb-1">
|
||||
Free Tier Unavailable At This Time
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Due to extremely high demand, we cannot offer a free tier at the moment. Upgrade to Pro to continue using our service.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: -10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
{/* Custom plan comparison wrapper to show Pro, Enterprise and Self-Host side by side */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
{premiumPlans.map((tier) => (
|
||||
<div key={tier.name} className="border border-border rounded-lg p-3">
|
||||
<div className="text-center mb-2">
|
||||
<h3 className="font-medium">{tier.name}</h3>
|
||||
<p className="text-sm font-bold">{tier.price}/mo</p>
|
||||
<p className="text-xs text-muted-foreground">{tier.hours}/month</p>
|
||||
</div>
|
||||
<form>
|
||||
<input type="hidden" name="accountId" value={accountId || ''} />
|
||||
<input type="hidden" name="returnUrl" value={returnUrl} />
|
||||
<input type="hidden" name="planId" value={
|
||||
tier.name === 'Pro'
|
||||
? siteConfig.cloudPricingItems.find(item => item.name === 'Pro')?.stripePriceId || ''
|
||||
: siteConfig.cloudPricingItems.find(item => item.name === 'Enterprise')?.stripePriceId || ''
|
||||
} />
|
||||
<SubmitButton
|
||||
pendingText="..."
|
||||
formAction={setupNewSubscription}
|
||||
className={cn(
|
||||
"w-full font-medium transition-colors h-7 rounded-md text-xs",
|
||||
tier.buttonColor
|
||||
)}
|
||||
>
|
||||
<AlertDialogTitle className="text-2xl font-bold text-center text-primary bg-clip-text">
|
||||
High Demand Notice
|
||||
</AlertDialogTitle>
|
||||
</motion.div>
|
||||
Upgrade
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<motion.div
|
||||
initial={{ y: -10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<AlertDialogDescription className="text-base text-center leading-relaxed">
|
||||
Due to exceptionally high demand, our service is currently experiencing slower response times.
|
||||
We recommend returning tomorrow when our systems will be operating at normal capacity.
|
||||
<span className="mt-4 block font-medium text-primary">Thank you for your understanding. We will notify you via email once the service is fully operational again.</span>
|
||||
</AlertDialogDescription>
|
||||
</motion.div>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter className="p-8 pt-4 border-t border-border/40 bg-background/40 backdrop-blur-sm">
|
||||
{/* Self-host Option as the third card */}
|
||||
<div className="border border-border rounded-lg p-3">
|
||||
<div className="text-center mb-2">
|
||||
<h3 className="font-medium">Self-Host</h3>
|
||||
<p className="text-sm font-bold">Free</p>
|
||||
<p className="text-xs text-muted-foreground">Open Source</p>
|
||||
</div>
|
||||
<Link
|
||||
href="https://github.com/kortix-ai/suna"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mx-auto w-full flex items-center justify-center gap-3 bg-gradient-to-tr from-primary to-primary/80 hover:opacity-90 text-white font-medium rounded-full px-8 py-3 transition-all hover:shadow-md"
|
||||
className="w-full flex items-center justify-center gap-1 h-7 bg-gradient-to-tr from-primary to-primary/80 hover:opacity-90 text-white font-medium rounded-md text-xs transition-all"
|
||||
>
|
||||
<Github className="h-5 w-5 transition-transform group-hover:scale-110" />
|
||||
<span>Explore Self-Hosted Version</span>
|
||||
<Github className="h-3.5 w-3.5" />
|
||||
<span>Self-Host</span>
|
||||
</Link>
|
||||
</AlertDialogFooter>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Portal>
|
||||
)
|
||||
}
|
|
@ -83,9 +83,9 @@ export const siteConfig = {
|
|||
buttonText: "Hire Suna",
|
||||
buttonColor: "bg-secondary text-white",
|
||||
isPopular: false,
|
||||
hours: "10 minutes",
|
||||
hours: "no free usage at this time",
|
||||
features: [
|
||||
"10 minutes usage per month",
|
||||
"no free usage",
|
||||
// "Community support",
|
||||
// "Single user",
|
||||
// "Standard response time",
|
||||
|
|
Loading…
Reference in New Issue