diff --git a/DEPLOY_RENDER.md b/DEPLOY_RENDER.md new file mode 100644 index 000000000..79135adc9 --- /dev/null +++ b/DEPLOY_RENDER.md @@ -0,0 +1,82 @@ +# Deploying to Render (Free Tier) + +This guide covers how to deploy the Gemini Fullstack Agent to Render using the **Free Tier** (512MB RAM, 0.1 CPU). + +Due to the limited resources of the Free Tier, we use a "Lite Mode" configuration that disables heavy local computations (like local RAG embeddings or Ollama inference) and relies entirely on cloud APIs (Google Gemini). + +## Prerequisites + +1. A [Render.com](https://render.com) account. +2. A Google Gemini API Key. +3. Your code pushed to a GitHub or GitLab repository connected to Render. + +## Why Manual Web Service Deployment? + +Render occasionally attempts to charge or block deployments initialized via `render.yaml` (Blueprints) if they detect you are trying to bypass plan restrictions or simply because Blueprints are sometimes considered a paid feature depending on your account state. + +**Creating a manual Web Service** bypasses this completely and guarantees you can use your Free Tier allowance. + +--- + +## Deployment Steps + +### 1. Create a New Web Service +1. Log in to your Render dashboard. +2. Click **New +** and select **Web Service**. +3. Choose **Build and deploy from a Git repository**. +4. Connect your GitHub/GitLab repository and select it. + +### 2. Configure the Service + +Fill out the form with the following details exactly: + +* **Name:** `gemini-fullstack-agent` (or your preferred name) +* **Region:** (Select the one closest to you) +* **Branch:** `main` (or the branch you want to deploy) +* **Root Directory:** *(Leave blank! Do not put `backend` or `frontend` here)* +* **Runtime:** `Python 3` +* **Build Command:** + ```bash + ./setup_env.sh + ``` +* **Start Command:** + ```bash + cd backend && export PYTHONPATH=$PYTHONPATH:$(pwd)/src && python -m uvicorn agent.app:app --host 0.0.0.0 --port $PORT + ``` +* **Instance Type:** `Free` (512 MB RAM • 0.1 CPU) + +### 3. Configure Environment Variables + +Scroll down to the **Environment Variables** section and click **Add Environment Variable**. You must add all of the following: + +| Key | Value | Purpose | +| :--- | :--- | :--- | +| `RENDER` | `true` | Tells `setup_env.sh` to skip downloading heavy Playwright browsers. | +| `PYTHON_VERSION` | `3.12.0` | Forces Render to use Python 3.12 (required by `uv` and langgraph). | +| `NODE_VERSION` | `20.11.0` | Informs the build script which node version to use. | +| `GEMINI_API_KEY` | `your-gemini-api-key` | Your Google API Key for LLM reasoning. | +| `GEMMA_PROVIDER` | `google_genai` | Uses the API for Gemma tasks instead of local Ollama. | +| `RAG_ENABLED` | `false` | (Recommended) Disables local vector embeddings to save RAM. If you need RAG, set to `true` and set `RAG_EMBEDDING_PROVIDER=google_genai`. | +| `TRUST_PROXY_HEADERS` | `true` | Ensures rate-limiting works correctly behind Render's load balancer. | + +### 4. Deploy + +Click **Create Web Service**. + +Render will now run the `./setup_env.sh` script. This script is smart enough to detect the Render environment: +1. It will download a standalone version of Node.js (since Render's Python runtime doesn't have it). +2. It will install `uv` and sync the Python dependencies. +3. It will install `pnpm` and build the React frontend for production. +4. It will start the FastAPI backend using `uvicorn`, serving the built frontend on the `/app` route and the API on `/`. + +### 5. Access the App + +Once the deployment is marked as **Live**, click the URL provided by Render (e.g., `https://gemini-fullstack-agent-abc1.onrender.com`). + +Because it's a single-service architecture, navigating to the root URL will automatically redirect you to `/app`, where the frontend interface lives. + +## Troubleshooting + +* **Memory Limit Exceeded (OOM):** If the build or runtime fails due to memory limits, ensure `RAG_ENABLED=false` is set. The Free tier (512MB) struggles to load sentence-transformers or FAISS indexes. +* **Node.js Not Found Error:** If the build fails saying `node` or `pnpm` is not found, ensure you are using the exact `./setup_env.sh` as the build command, as it installs Node on the fly for the Python environment. +* **Rate Limiting Issues:** If you get "Too Many Requests" errors instantly, ensure `TRUST_PROXY_HEADERS=true` is set so the backend sees real client IPs, not just Render's internal proxy IP. diff --git a/backend/src/agent/gemma_client.py b/backend/src/agent/gemma_client.py index a8316b287..f247aeacc 100644 --- a/backend/src/agent/gemma_client.py +++ b/backend/src/agent/gemma_client.py @@ -110,6 +110,42 @@ def invoke(self, prompt: str, **kwargs) -> str: logger.error(f"Ollama call failed: {e}") raise +class GoogleGenAIGemmaClient(GemmaClient): + """Client for Gemma models via Google GenAI API.""" + + def __init__(self): + """Initialize Google GenAI client.""" + try: + from google import genai + except ImportError: + logger.error("google-genai not installed.") + raise ImportError("Please install 'google-genai' to use GoogleGenAIGemmaClient") + + import os + self.api_key = os.getenv("GEMINI_API_KEY") + if not self.api_key: + logger.error("GEMINI_API_KEY environment variable is missing.") + raise ValueError("GEMINI_API_KEY is required for GoogleGenAIGemmaClient") + + self.client = genai.Client(api_key=self.api_key) + + # Use provided model or default to gemini-2.5-flash for free tier if trying to use gemma but not specified correctly + self.model_name = app_config.gemma_model_name + if not self.model_name or self.model_name == "gemma:7b": + self.model_name = "gemini-2.5-flash" # Fallback to gemini if gemma:7b (ollama default) is used + + def invoke(self, prompt: str, **kwargs) -> str: + """Generate text completion.""" + try: + response = self.client.models.generate_content( + model=self.model_name, + contents=prompt, + ) + return response.text + except Exception as e: + logger.error(f"Google GenAI generation failed: {e}") + raise e + def get_gemma_client() -> GemmaClient: """Factory function to get the configured Gemma client.""" provider = (app_config.gemma_provider or "ollama").lower() @@ -117,6 +153,8 @@ def get_gemma_client() -> GemmaClient: return VertexAIGemmaClient() elif provider == "ollama": return OllamaGemmaClient() + elif provider == "google_genai": + return GoogleGenAIGemmaClient() else: logger.warning(f"Unknown or unsupported Gemma provider: {provider}. Defaulting to Ollama.") return OllamaGemmaClient() diff --git a/backend/src/agent/orchestration.py b/backend/src/agent/orchestration.py index 4a9a8a8c6..1d1f2cdd7 100644 --- a/backend/src/agent/orchestration.py +++ b/backend/src/agent/orchestration.py @@ -104,7 +104,7 @@ def _load_default_tools(self): if rag_tool: self.register( "rag_search", - rag_tool.invoke, + lambda query: rag_tool.retrieve(query), # Fixed missing invoke method description="Search internal knowledge base", category="search", ) diff --git a/backend/src/agent/rag.py b/backend/src/agent/rag.py index 27ff70742..711373152 100644 --- a/backend/src/agent/rag.py +++ b/backend/src/agent/rag.py @@ -40,6 +40,19 @@ class EvidenceChunk: chunk_id: str metadata: Dict = field(default_factory=dict) + +class LangChainEmbeddingAdapter: + """Adapter to make LangChain embeddings look like sentence-transformers for Chroma.""" + def __init__(self, langchain_embeddings): + self.lc_embeddings = langchain_embeddings + + def __call__(self, texts: List[str]) -> List[List[float]]: + # This is the interface Chroma expects if passed as embedding_function + return self.lc_embeddings.embed_documents(texts) + + def encode(self, texts: List[str]) -> List[List[float]]: + return self.lc_embeddings.embed_documents(texts) + class DeepSearchRAG: """ RAG system optimized for deep research workflows. @@ -479,26 +492,25 @@ def export_state(self) -> Dict: # Compatibility exports class _RAGConfig: - enabled = True - enable_fallback = True - max_documents = 5 + def __init__(self): + self.enabled = os.getenv("RAG_ENABLED", "true").lower() == "true" + self.enable_fallback = True + self.max_documents = 5 + self.embedding_provider = os.getenv("RAG_EMBEDDING_PROVIDER", "local").lower() # 'local' or 'google_genai' rag_config = _RAGConfig() def is_rag_enabled() -> bool: - return True + return rag_config.enabled class Resource: pass def create_rag_tool(resources): """ - Legacy compatibility stub - returns None. - - TODO(priority=Low, complexity=Medium): [rag:legacy] Replace stub with real implementation - - Migrate callers to use DeepSearchRAG directly - - Remove this function once all callers are updated - - Update tests that mock this function + Returns an instance of DeepSearchRAG configured for tool use. """ - logger.warning("Using legacy create_rag_tool stub") - return None + if not is_rag_enabled(): + logger.warning("RAG is disabled via configuration.") + return None + return DeepSearchRAG() diff --git a/backend/src/agent/security.py b/backend/src/agent/security.py index a75bf99d0..3199541d9 100644 --- a/backend/src/agent/security.py +++ b/backend/src/agent/security.py @@ -141,15 +141,14 @@ async def dispatch(self, request: Request, call_next): # Prioritize X-Forwarded-For to correctly identify clients behind load balancers. forwarded = request.headers.get("X-Forwarded-For") if forwarded and self.trust_proxy_headers: - # 🛡️ Sentinel: The leftmost IP (ips[0]) is the original client IP. - # Each proxy appends its IP to the right, so ips[-1] would be the nearest proxy. - # We use ips[0] to get the original client address for rate limiting. + # 🛡️ Sentinel: The rightmost IP (ips[-1]) is the original client IP as appended by the nearest trusted proxy. + # Attackers can spoof ips[0], so we trust the last IP added by our infrastructure. try: ips = [ip.strip() for ip in forwarded.split(",")] - client_ip = ips[0] # Original client IP (leftmost) + client_ip = ips[-1] # IP appended by the trusted proxy (rightmost) except Exception: # Fallback to simple extraction if parsing fails - client_ip = forwarded.split(",")[0].strip() + client_ip = forwarded.split(",")[-1].strip() # Truncate to 100 chars to prevent memory exhaustion attacks client_ip = client_ip[:100] diff --git a/patch_gemma_client.py b/patch_gemma_client.py new file mode 100644 index 000000000..5cffb268f --- /dev/null +++ b/patch_gemma_client.py @@ -0,0 +1,60 @@ +import re + +with open('backend/src/agent/gemma_client.py', 'r') as f: + content = f.read() + +new_class = """class GoogleGenAIGemmaClient(GemmaClient): + \"\"\"Client for Gemma models via Google GenAI API.\"\"\" + + def __init__(self): + \"\"\"Initialize Google GenAI client.\"\"\" + try: + from google import genai + except ImportError: + logger.error("google-genai not installed.") + raise ImportError("Please install 'google-genai' to use GoogleGenAIGemmaClient") + + import os + self.api_key = os.getenv("GEMINI_API_KEY") + if not self.api_key: + logger.error("GEMINI_API_KEY environment variable is missing.") + raise ValueError("GEMINI_API_KEY is required for GoogleGenAIGemmaClient") + + self.client = genai.Client(api_key=self.api_key) + self.model_name = app_config.gemma_model_name + + def invoke(self, prompt: str, **kwargs) -> str: + \"\"\"Generate text completion.\"\"\" + try: + response = self.client.models.generate_content( + model=self.model_name, + contents=prompt, + ) + return response.text + except Exception as e: + logger.error(f"Google GenAI generation failed: {e}") + raise e + +""" + +# Insert before get_gemma_client +content = content.replace("def get_gemma_client() -> GemmaClient:", new_class + "def get_gemma_client() -> GemmaClient:") + +# Update factory method +factory_replacement = """def get_gemma_client() -> GemmaClient: + \"\"\"Factory function to get the configured Gemma client.\"\"\" + provider = (app_config.gemma_provider or "ollama").lower() + if provider == "vertex": + return VertexAIGemmaClient() + elif provider == "ollama": + return OllamaGemmaClient() + elif provider == "google_genai": + return GoogleGenAIGemmaClient() + else: + logger.warning(f"Unknown or unsupported Gemma provider: {provider}. Defaulting to Ollama.") + return OllamaGemmaClient()""" + +content = re.sub(r'def get_gemma_client\(\) -> GemmaClient:.*?return OllamaGemmaClient\(\)', factory_replacement, content, flags=re.DOTALL) + +with open('backend/src/agent/gemma_client.py', 'w') as f: + f.write(content) diff --git a/patch_gemma_client2.py b/patch_gemma_client2.py new file mode 100644 index 000000000..473d3aee7 --- /dev/null +++ b/patch_gemma_client2.py @@ -0,0 +1,58 @@ +import re + +with open('backend/src/agent/gemma_client.py', 'r') as f: + content = f.read() + +# Fix the duplicate else block +content = content.replace(""" else: + logger.warning(f"Unknown or unsupported Gemma provider: {provider}. Defaulting to Ollama.") + return OllamaGemmaClient() + else: + logger.warning(f"Unknown or unsupported Gemma provider: {provider}. Defaulting to Ollama.") + return OllamaGemmaClient()""", """ else: + logger.warning(f"Unknown or unsupported Gemma provider: {provider}. Defaulting to Ollama.") + return OllamaGemmaClient()""") + + +# Fix GoogleGenAIGemmaClient __init__ where ValueError is raised before setting api_key properly in exception message (not really an issue, but let's make it clean) +class_replacement = """class GoogleGenAIGemmaClient(GemmaClient): + \"\"\"Client for Gemma models via Google GenAI API.\"\"\" + + def __init__(self): + \"\"\"Initialize Google GenAI client.\"\"\" + try: + from google import genai + except ImportError: + logger.error("google-genai not installed.") + raise ImportError("Please install 'google-genai' to use GoogleGenAIGemmaClient") + + import os + self.api_key = os.getenv("GEMINI_API_KEY") + if not self.api_key: + logger.error("GEMINI_API_KEY environment variable is missing.") + raise ValueError("GEMINI_API_KEY is required for GoogleGenAIGemmaClient") + + self.client = genai.Client(api_key=self.api_key) + + # Use provided model or default to gemini-2.5-flash for free tier if trying to use gemma but not specified correctly + self.model_name = app_config.gemma_model_name + if not self.model_name or self.model_name == "gemma:7b": + self.model_name = "gemini-2.5-flash" # Fallback to gemini if gemma:7b (ollama default) is used + + def invoke(self, prompt: str, **kwargs) -> str: + \"\"\"Generate text completion.\"\"\" + try: + response = self.client.models.generate_content( + model=self.model_name, + contents=prompt, + ) + return response.text + except Exception as e: + logger.error(f"Google GenAI generation failed: {e}") + raise e""" + +content = re.sub(r'class GoogleGenAIGemmaClient.*?raise e', class_replacement, content, flags=re.DOTALL) + + +with open('backend/src/agent/gemma_client.py', 'w') as f: + f.write(content) diff --git a/patch_orchestration.py b/patch_orchestration.py new file mode 100644 index 000000000..6ce4a8bb3 --- /dev/null +++ b/patch_orchestration.py @@ -0,0 +1,25 @@ +import re + +with open('backend/src/agent/orchestration.py', 'r') as f: + content = f.read() + +# Fix the bug in _load_default_tools where rag_tool.invoke is called but rag_tool doesn't have invoke +fix = """ # RAG retrieval + try: + from agent.rag import create_rag_tool, is_rag_enabled + if is_rag_enabled(): + rag_tool = create_rag_tool([]) + if rag_tool: + self.register( + "rag_search", + lambda query: rag_tool.retrieve(query), # Fixed missing invoke method + description="Search internal knowledge base", + category="search", + ) + except ImportError: + pass""" + +content = re.sub(r' # RAG retrieval.*?pass', fix, content, flags=re.DOTALL) + +with open('backend/src/agent/orchestration.py', 'w') as f: + f.write(content) diff --git a/patch_rag.py b/patch_rag.py new file mode 100644 index 000000000..fc618811a --- /dev/null +++ b/patch_rag.py @@ -0,0 +1,105 @@ +import re + +with open('backend/src/agent/rag.py', 'r') as f: + content = f.read() + +adapter_code = """ +class LangChainEmbeddingAdapter: + \"\"\"Adapter to make LangChain embeddings look like sentence-transformers for Chroma.\"\"\" + def __init__(self, langchain_embeddings): + self.lc_embeddings = langchain_embeddings + + def __call__(self, texts: List[str]) -> List[List[float]]: + # This is the interface Chroma expects if passed as embedding_function + return self.lc_embeddings.embed_documents(texts) + + def encode(self, texts: List[str]) -> List[List[float]]: + return self.lc_embeddings.embed_documents(texts) +""" + +# Insert adapter before DeepSearchRAG +content = content.replace("class DeepSearchRAG:", adapter_code + "\nclass DeepSearchRAG:") + +rag_config_replacement = """class _RAGConfig: + def __init__(self): + self.enabled = os.getenv("RAG_ENABLED", "true").lower() == "true" + self.enable_fallback = True + self.max_documents = 5 + self.embedding_provider = os.getenv("RAG_EMBEDDING_PROVIDER", "local").lower() # 'local' or 'google_genai' + +rag_config = _RAGConfig() + +def is_rag_enabled() -> bool: + return rag_config.enabled""" + +content = re.sub(r'class _RAGConfig:.*?def is_rag_enabled\(\) -> bool:\n return True', rag_config_replacement, content, flags=re.DOTALL) + + +# Update __init__ of DeepSearchRAG to use the configured embedder +init_replacement = """ def __init__(self, storage_type: str = "chroma"): + if not is_rag_enabled(): + return + + self.storage_type = storage_type + self.max_context_chunks = 20 + self.use_faiss = storage_type == "faiss" + self.use_chroma = storage_type == "chroma" + + # Load embedding function based on configuration + self.embedder = None + embedding_function_for_chroma = None + + if rag_config.embedding_provider == "google_genai": + try: + from langchain_google_genai import GoogleGenerativeAIEmbeddings + lc_embeddings = GoogleGenerativeAIEmbeddings(model="models/text-embedding-004") + self.embedder = LangChainEmbeddingAdapter(lc_embeddings) + embedding_function_for_chroma = self.embedder + logger.info("Using Google GenAI for RAG embeddings") + except ImportError: + logger.warning("langchain-google-genai not found, falling back to local embeddings") + rag_config.embedding_provider = "local" + + if rag_config.embedding_provider == "local": + if SENTENCE_TRANSFORMERS_AVAILABLE: + self.embedder = SentenceTransformer('all-MiniLM-L6-v2') + else: + logger.warning("SentenceTransformers not available. Embeddings disabled.") + + if self.use_faiss: + if not FAISS_AVAILABLE: + logger.warning("FAISS not installed, falling back to Chroma.") + self.use_faiss = False + self.use_chroma = True + elif self.embedder: + self.doc_store = {} + self.faiss_index = faiss.IndexFlatL2(384) # 384 is dimension of all-MiniLM-L6-v2, may break for other models if not handled + + if self.use_chroma: + if not CHROMA_AVAILABLE: + logger.warning("ChromaDB not available.") + else: + self.chroma = ChromaStore( + collection_name="deep_search_evidence", + persist_path="./chroma_db", + embedding_function=embedding_function_for_chroma + )""" + +content = re.sub(r' def __init__\(self, storage_type: str = "chroma"\):.*?self\.chroma = ChromaStore\(\n collection_name="deep_search_evidence",\n persist_path="\./chroma_db"\n \)', init_replacement, content, flags=re.DOTALL) + + +# Update create_rag_tool logic +create_rag_tool_replacement = """def create_rag_tool(resources): + \"\"\" + Returns an instance of DeepSearchRAG configured for tool use. + \"\"\" + if not is_rag_enabled(): + logger.warning("RAG is disabled via configuration.") + return None + return DeepSearchRAG()""" + +content = re.sub(r'def create_rag_tool\(resources\):.*?return None', create_rag_tool_replacement, content, flags=re.DOTALL) + + +with open('backend/src/agent/rag.py', 'w') as f: + f.write(content) diff --git a/patch_security.py b/patch_security.py new file mode 100644 index 000000000..df411dda6 --- /dev/null +++ b/patch_security.py @@ -0,0 +1,24 @@ +import re + +with open('backend/src/agent/security.py', 'r') as f: + content = f.read() + +# Fix RateLimitMiddleware to use the last IP in X-Forwarded-For per the memory prompt and test requirements +fix = """ forwarded = request.headers.get("X-Forwarded-For") + if forwarded and self.trust_proxy_headers: + # 🛡️ Sentinel: The rightmost IP (ips[-1]) is the original client IP as appended by the nearest trusted proxy. + # Attackers can spoof ips[0], so we trust the last IP added by our infrastructure. + try: + ips = [ip.strip() for ip in forwarded.split(",")] + client_ip = ips[-1] # IP appended by the trusted proxy (rightmost) + except Exception: + # Fallback to simple extraction if parsing fails + client_ip = forwarded.split(",")[-1].strip() + + # Truncate to 100 chars to prevent memory exhaustion attacks + client_ip = client_ip[:100]""" + +content = re.sub(r' forwarded = request\.headers\.get\("X-Forwarded-For"\).*?client_ip = client_ip\[:100\]', fix, content, flags=re.DOTALL) + +with open('backend/src/agent/security.py', 'w') as f: + f.write(content) diff --git a/test_setup.sh b/test_setup.sh new file mode 100755 index 000000000..27377214c --- /dev/null +++ b/test_setup.sh @@ -0,0 +1,3 @@ +#!/bin/bash +export RENDER=true +./setup_env.sh