diff --git a/README.md b/README.md index c2118f59..4af27e16 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ source venv/bin/activate # Windows: venv\Scripts\activate pip install -r requirements.txt ``` -3. **Set your API keys (for LLMs)** +3. **Set your API keys (for LLMs)** – or use OpenRouter one-click on Render Copy the example environment file and add your API keys: @@ -101,6 +101,15 @@ In the web UI you can: For more detailed setup options (API, search backend, jobs), see `INSTALL.md`. +### One-click AI access (Render + OpenRouter) + +When deploying to Render with `USE_OPENROUTER_OAUTH=true`, users get AI access without API keys or credit card: + +1. Deploy to Render (see `render.yaml`). +2. Users open Settings → **Connect OpenRouter** → authorize → done (50 free requests/day). + +(Render sets `RENDER_EXTERNAL_URL` automatically for the OAuth callback.) + --- ## Use Cases diff --git a/render.yaml b/render.yaml index 3d27646f..c215e992 100644 --- a/render.yaml +++ b/render.yaml @@ -18,10 +18,17 @@ services: value: "true" - key: USE_POSTGRES_FILE_STORAGE value: "true" + # OpenRouter OAuth: one-click AI access, no credit card + - key: USE_OPENROUTER_OAUTH + value: "true" + - key: OPENAI_API_BASE + value: "https://openrouter.ai/api/v1" + - key: OPENAI_API_MODEL + value: openai/gpt-4o-mini + # RENDER_EXTERNAL_URL is set automatically by Render for the callback + # Optional: override with your own keys for non-OAuth usage - key: OPENAI_API_KEY sync: false - - key: OPENAI_API_MODEL - value: gpt-4o-mini - key: GOOGLE_API_KEY sync: false diff --git a/report_analyst/core/analyzer.py b/report_analyst/core/analyzer.py index 075e3c9c..c59c0751 100644 --- a/report_analyst/core/analyzer.py +++ b/report_analyst/core/analyzer.py @@ -61,29 +61,39 @@ if not gemini_key: gemini_key = "backend-handles-llm" else: - # Only check for API keys if not using backend LLM - # Check if we need to force the default model based on available keys - if default_model.startswith("gemini-") and not gemini_key: - logger.warning(f"Default model is {default_model} but no GOOGLE_API_KEY is available") - if openai_key: - default_model = "gpt-3.5-turbo-1106" - logger.info(f"Switching default model to {default_model}") - else: - logger.error("No valid API keys available for any models") - raise ValueError("No valid API keys found. Set either OPENAI_API_KEY or GOOGLE_API_KEY") - elif default_model.startswith("gpt-") and not openai_key: - logger.warning(f"Default model is {default_model} but no OPENAI_API_KEY is available") - if gemini_key: - default_model = "gemini-pro" - logger.info(f"Switching default model to {default_model}") - else: - logger.error("No valid API keys available for any models") - raise ValueError("No valid API keys found. Set either OPENAI_API_KEY or GOOGLE_API_KEY") + # OpenRouter OAuth mode: allow startup without keys (user connects via OAuth) + use_openrouter_oauth = os.getenv("USE_OPENROUTER_OAUTH", "false").lower() == "true" + if use_openrouter_oauth and not openai_key and not gemini_key: + logger.info("OpenRouter OAuth mode - app will start without keys, user connects via OAuth") + default_model = os.getenv("OPENAI_API_MODEL", "openai/gpt-4o-mini") + openai_key = None # Will be set at runtime after OAuth + gemini_key = None + else: + # Only check for API keys if not using OpenRouter OAuth + if default_model.startswith("gemini-") and not gemini_key: + logger.warning(f"Default model is {default_model} but no GOOGLE_API_KEY is available") + if openai_key: + default_model = "gpt-3.5-turbo-1106" + logger.info(f"Switching default model to {default_model}") + else: + logger.error("No valid API keys available for any models") + raise ValueError("No valid API keys found. Set either OPENAI_API_KEY or GOOGLE_API_KEY") + elif default_model.startswith("gpt-") and not openai_key: + logger.warning(f"Default model is {default_model} but no OPENAI_API_KEY is available") + if gemini_key: + default_model = "gemini-pro" + logger.info(f"Switching default model to {default_model}") + else: + logger.error("No valid API keys available for any models") + raise ValueError("No valid API keys found. Set either OPENAI_API_KEY or GOOGLE_API_KEY") + + # Ensure we have at least one API key for the selected model type + if not openai_key and not gemini_key: + logger.error("No API keys found - set either OPENAI_API_KEY or GOOGLE_API_KEY") + raise ValueError("Set either OPENAI_API_KEY or GOOGLE_API_KEY environment variable") - # Ensure we have at least one API key for the selected model type - if not openai_key and not gemini_key: - logger.error("No API keys found - set either OPENAI_API_KEY or GOOGLE_API_KEY") - raise ValueError("Set either OPENAI_API_KEY or GOOGLE_API_KEY environment variable") +# Track OpenRouter OAuth mode for deferred LLM init +use_openrouter_oauth = os.getenv("USE_OPENROUTER_OAUTH", "false").lower() == "true" if not os.getenv("OPENAI_ORGANIZATION"): logger.warning("OPENAI_ORGANIZATION environment variable is not set") @@ -156,6 +166,13 @@ def __init__(self): # Set minimal placeholders for compatibility self.llm = None self.embeddings = None + elif use_openrouter_oauth and not openai_key and not gemini_key: + log_analysis_step( + "OpenRouter OAuth mode - deferring LLM init until user connects", + "info", + ) + self.llm = None + self.embeddings = None else: try: # Initialize LLM with caching using the provider factory diff --git a/report_analyst/core/llm_providers.py b/report_analyst/core/llm_providers.py index 0fd9808c..f9961bf7 100644 --- a/report_analyst/core/llm_providers.py +++ b/report_analyst/core/llm_providers.py @@ -13,13 +13,23 @@ # Setup logging logger = logging.getLogger(__name__) +# OpenRouter API base - used when OPENAI_API_BASE points to OpenRouter +OPENROUTER_API_BASE = "https://openrouter.ai/api/v1" + + +def _is_openrouter() -> bool: + """Check if we're using OpenRouter (via api_base or explicit flag).""" + api_base = os.getenv("OPENAI_API_BASE") + use_openrouter = os.getenv("USE_OPENROUTER_OAUTH", "false").lower() == "true" + return use_openrouter or (api_base and "openrouter.ai" in api_base) + def get_llm(model_name: str, cache_dir: Optional[str] = None, **kwargs) -> Any: """ Factory function to get LLM implementations based on model name. Args: - model_name: Name of the model to use (e.g., "gpt-4o", "gemini-flash-2.0") + model_name: Name of the model to use (e.g., "gpt-4o", "openai/gpt-4o-mini") cache_dir: Optional directory for LLM response caching **kwargs: Additional keyword arguments to pass to the LLM constructor @@ -30,7 +40,29 @@ def get_llm(model_name: str, cache_dir: Optional[str] = None, **kwargs) -> Any: ValueError: If the API key for the selected model is not available ValueError: If the model type is not supported """ - # OpenAI models + # OpenRouter: uses OpenAI-compatible API with openrouter.ai base + if _is_openrouter(): + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + logger.error( + f"Cannot initialize OpenRouter model '{model_name}' - " "OPENAI_API_KEY not set (connect via OpenRouter OAuth)" + ) + raise ValueError("Connect OpenRouter to get AI access. No API key available.") + api_base = os.getenv("OPENAI_API_BASE", OPENROUTER_API_BASE) + # OpenRouter model IDs: openai/gpt-4o-mini, anthropic/claude-3-haiku, etc. + if not model_name.startswith(("openai/", "anthropic/", "google/", "meta/")): + openrouter_model = f"openai/{model_name}" if "gpt" in model_name else model_name + else: + openrouter_model = model_name + return OpenAI( + model=openrouter_model, + api_key=api_key, + api_base=api_base, + cache_dir=cache_dir, + **kwargs, + ) + + # OpenAI models (direct) if model_name.startswith("gpt-"): api_key = os.getenv("OPENAI_API_KEY") if not api_key: diff --git a/report_analyst/core/openrouter_oauth.py b/report_analyst/core/openrouter_oauth.py new file mode 100644 index 00000000..deffb55e --- /dev/null +++ b/report_analyst/core/openrouter_oauth.py @@ -0,0 +1,136 @@ +""" +OpenRouter OAuth PKCE flow for one-click AI access. + +Allows users to connect their OpenRouter account without manually copying API keys. +Uses PKCE (Proof Key for Code Exchange) for secure authorization. +""" + +import base64 +import hashlib +import json +import logging +import secrets +from pathlib import Path +from typing import Optional, Tuple + +import httpx + +logger = logging.getLogger(__name__) + +OPENROUTER_AUTH_URL = "https://openrouter.ai/auth" +OPENROUTER_EXCHANGE_URL = "https://openrouter.ai/api/v1/auth/keys" + + +def _get_state_file() -> Path: + """Get path to OAuth state storage file.""" + storage = Path(__file__).parent.parent.parent / "storage" + storage.mkdir(parents=True, exist_ok=True) + return storage / "openrouter_oauth_state.json" + + +def _load_state() -> dict: + """Load OAuth state from file.""" + path = _get_state_file() + if not path.exists(): + return {} + try: + with open(path) as f: + return json.load(f) + except (json.JSONDecodeError, IOError) as e: + logger.warning(f"Could not load OAuth state: {e}") + return {} + + +def _save_state(data: dict) -> None: + """Save OAuth state to file.""" + path = _get_state_file() + with open(path, "w") as f: + json.dump(data, f, indent=2) + + +def generate_pkce() -> Tuple[str, str]: + """ + Generate PKCE code_verifier and code_challenge (S256). + + Returns: + Tuple of (code_verifier, code_challenge) + """ + code_verifier = secrets.token_urlsafe(32) + digest = hashlib.sha256(code_verifier.encode()).digest() + code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=") + return code_verifier, code_challenge + + +def store_oauth_state(state: str, code_verifier: str) -> None: + """Store OAuth state and code_verifier for callback.""" + data = _load_state() + data[state] = { + "code_verifier": code_verifier, + "code_challenge_method": "S256", + } + _save_state(data) + + +def get_and_clear_oauth_state(state: str) -> Optional[dict]: + """Retrieve and remove OAuth state. Returns None if not found.""" + data = _load_state() + entry = data.pop(state, None) + if entry: + _save_state(data) + return entry + + +def get_auth_url(callback_url: str) -> Tuple[str, str]: + """ + Build OpenRouter auth URL and return (url, state). + + Caller must store state with code_verifier before redirecting user. + + Returns: + Tuple of (auth_url, state) + """ + state = secrets.token_urlsafe(16) + code_verifier, code_challenge = generate_pkce() + store_oauth_state(state, code_verifier) + url = f"{OPENROUTER_AUTH_URL}?callback_url={callback_url}&code_challenge={code_challenge}&code_challenge_method=S256" + return url, state + + +def exchange_code_for_key( + code: str, + code_verifier: str, + code_challenge_method: str = "S256", +) -> str: + """ + Exchange authorization code for OpenRouter API key. + + Args: + code: Authorization code from OpenRouter callback + code_verifier: PKCE code verifier + code_challenge_method: Method used (S256 or plain) + + Returns: + OpenRouter API key + + Raises: + httpx.HTTPStatusError: On exchange failure + ValueError: If response has no key + """ + payload = { + "code": code, + "code_verifier": code_verifier, + "code_challenge_method": code_challenge_method, + } + with httpx.Client() as client: + resp = client.post( + OPENROUTER_EXCHANGE_URL, + json=payload, + headers={"Content-Type": "application/json"}, + timeout=30.0, + ) + resp.raise_for_status() + data = resp.json() + key = data.get("key") + if not key: + raise ValueError("OpenRouter exchange response missing 'key'") + return key diff --git a/report_analyst/streamlit_app.py b/report_analyst/streamlit_app.py index b059e9bd..ece80181 100644 --- a/report_analyst/streamlit_app.py +++ b/report_analyst/streamlit_app.py @@ -77,6 +77,11 @@ def log_analysis_step(message: str, level: str = "info"): create_analysis_dataframes, create_combined_dataframe, ) +from report_analyst.core.openrouter_oauth import ( + exchange_code_for_key, + get_and_clear_oauth_state, + get_auth_url, +) from report_analyst.core.prompt_manager import PromptManager from report_analyst.core.question_loader import get_question_loader @@ -89,14 +94,24 @@ def log_analysis_step(message: str, level: str = "info"): # Define model lists based on available API keys OPENAI_MODELS = ["gpt-4o-mini", "gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo"] +# OpenRouter model IDs (provider/model format) +OPENROUTER_MODELS = [ + "openai/gpt-4o-mini", + "openai/gpt-4o", + "anthropic/claude-3-haiku", + "anthropic/claude-3-sonnet", +] GEMINI_MODELS = ["gemini-1.5-flash", "gemini-1.5-pro"] # Only include models with available API keys LLM_MODELS = OPENAI_MODELS.copy() -# Check for Google API key and add Gemini models if available -if os.getenv("GOOGLE_API_KEY"): +# OpenRouter OAuth mode: use OpenRouter models when enabled +if os.getenv("USE_OPENROUTER_OAUTH", "false").lower() == "true": + LLM_MODELS = OPENROUTER_MODELS.copy() + logger.info("OpenRouter OAuth mode - using OpenRouter model list") +elif os.getenv("GOOGLE_API_KEY"): logger.info("Google API key found - adding Gemini models to available options") LLM_MODELS.extend(GEMINI_MODELS) else: @@ -1298,6 +1313,7 @@ def update_analyzer_parameters(): llm_model = st.session_state.new_llm_model # Validate selected model availability + use_openrouter = os.getenv("USE_OPENROUTER_OAUTH", "false").lower() == "true" if llm_model.startswith("gemini-") and not os.getenv("GOOGLE_API_KEY"): # If somehow a Gemini model was selected but no API key exists logger.error(f"Attempt to use Gemini model '{llm_model}' without API key") @@ -1305,9 +1321,11 @@ def update_analyzer_parameters(): # Reset to default OpenAI model llm_model = OPENAI_MODELS[0] st.session_state.new_llm_model = llm_model - elif llm_model.startswith("gpt-") and not os.getenv("OPENAI_API_KEY"): - logger.error(f"Attempt to use OpenAI model '{llm_model}' without API key") - st.error(f"OPENAI_API_KEY environment variable is not set. OpenAI models will not work correctly.") + elif (llm_model.startswith("gpt-") or llm_model.startswith("openai/") or use_openrouter) and not os.getenv( + "OPENAI_API_KEY" + ): + logger.error(f"Attempt to use model '{llm_model}' without API key") + st.error("Connect OpenRouter in Settings to get AI access, or set OPENAI_API_KEY.") # Update the analyzer with the new parameters try: @@ -1498,6 +1516,37 @@ def main(): st.set_page_config(page_title="Report Analyst", layout="wide") + # OpenRouter OAuth: handle callback and sync key to env + use_openrouter = os.getenv("USE_OPENROUTER_OAUTH", "false").lower() == "true" + if use_openrouter: + os.environ.setdefault("OPENAI_API_BASE", "https://openrouter.ai/api/v1") + # Handle OAuth callback (code + state in URL) + query_params = st.query_params + code = query_params.get("code") + state = query_params.get("state") + if code and state: + try: + oauth_state = get_and_clear_oauth_state(state) + if oauth_state: + api_key = exchange_code_for_key( + code=code, + code_verifier=oauth_state["code_verifier"], + code_challenge_method=oauth_state.get("code_challenge_method", "S256"), + ) + st.session_state["openrouter_api_key"] = api_key + os.environ["OPENAI_API_KEY"] = api_key + # Clear URL params + st.query_params.clear() + st.success("Connected to OpenRouter! AI access is ready.") + st.rerun() + else: + st.error("Invalid or expired OAuth state. Please try again.") + except Exception as e: + logger.exception("OpenRouter OAuth exchange failed") + st.error(f"Failed to connect: {e}") + elif "openrouter_api_key" in st.session_state: + os.environ["OPENAI_API_KEY"] = st.session_state["openrouter_api_key"] + # Inject Material Icons link tag at the top st.markdown( '', @@ -2734,6 +2783,23 @@ def main(): st.session_state.analyzer = ReportAnalyzer() analyzer = st.session_state.analyzer # Use the stored analyzer + # OpenRouter: refresh LLM when key became available after OAuth + if use_openrouter and os.getenv("OPENAI_API_KEY"): + if analyzer.analyzer.llm is None: + default_model = os.getenv("OPENAI_API_MODEL", "openai/gpt-4o-mini") + analyzer.analyzer.update_llm_model(default_model) + # Also init embeddings for OpenRouter + from llama_index.core import Settings + from llama_index.embeddings.openai import OpenAIEmbedding + + analyzer.analyzer.embeddings = OpenAIEmbedding( + api_key=os.getenv("OPENAI_API_KEY"), + api_base="https://openrouter.ai/api/v1", + model_name="text-embedding-ada-002", + embed_batch_size=100, + ) + Settings.embed_model = analyzer.analyzer.embeddings + except Exception as e: st.error(f"Error initializing analyzer: {str(e)}") st.exception(e) @@ -2807,10 +2873,33 @@ def main(): nav_options = ["Upload Report", "Report Analyst", "All Results", "Settings"] nav_page = st.sidebar.radio("", nav_options, key="nav_page", label_visibility="collapsed") - # Show page-specific content based on navigation - if nav_page == "Settings": - st.title("Settings") - st.caption("Configure application settings and integrations") + # Settings section in sidebar (consolidates all integration settings) + st.sidebar.markdown("---") + with st.sidebar.expander("Settings", expanded=False): + # OpenRouter Connect (when USE_OPENROUTER_OAUTH and no key) + if use_openrouter and not os.getenv("OPENAI_API_KEY"): + st.subheader("AI Access") + # Use OPENROUTER_CALLBACK_URL or Render's automatic RENDER_EXTERNAL_URL + callback_url = (os.getenv("OPENROUTER_CALLBACK_URL") or os.getenv("RENDER_EXTERNAL_URL", "")).rstrip("/") + if callback_url: + auth_url, _state = get_auth_url(callback_url) + st.link_button( + "Connect OpenRouter", + auth_url, + type="primary", + help="One-click AI access, no credit card. Free: 50 req/day.", + ) + st.caption("One-click AI access, no credit card. Free tier: 50 requests/day.") + else: + st.warning( + "Set OPENROUTER_CALLBACK_URL to your app URL to enable " + "Connect OpenRouter. (On Render, RENDER_EXTERNAL_URL is set automatically.)" + ) + st.divider() + # Show Enterprise Mode status at the top + use_s3_upload = st.session_state.get("use_s3_upload", False) + if use_s3_upload and BACKEND_INTEGRATION_AVAILABLE: + st.caption("Enterprise mode") # Open Source Modules Section st.header("Open Source Modules") @@ -3128,6 +3217,13 @@ def main(): st.info("PostgreSQL file storage requires a PostgreSQL database. Currently using SQLite.") st.caption("Files are stored in local temp directory") + # OpenRouter: show connect banner when no key + if use_openrouter and not os.getenv("OPENAI_API_KEY"): + st.info( + "**Get AI access** – Open Settings in the sidebar and click " + '"Connect OpenRouter" for one-click access (no credit card, 50 free requests/day).' + ) + # Show page-specific content based on navigation if nav_page == "Report Analyst": st.title("Report Analyst") diff --git a/tests/conftest.py b/tests/conftest.py index 821ed4ea..78f1f503 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,11 +17,16 @@ os.environ.setdefault("OPENBLAS_NUM_THREADS", "1") import json +import os import tempfile from unittest.mock import AsyncMock, Mock import pytest import yaml +from dotenv import load_dotenv + +# Load environment variables from .env file before any other imports +load_dotenv() from report_analyst_jobs.event_router import IGNORE_ACTION, EventRouter