Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4b29b3d
Add PostgreSQL support with SQLAlchemy adapter
suung Dec 13, 2025
3503070
Fix double info icons in Settings page
suung Dec 14, 2025
0ede76f
Add PostgreSQL migration system with Alembic
suung Dec 14, 2025
49b1bb9
Add database URL support, API key management, and PostgreSQL file sto…
suung Dec 14, 2025
6db2941
Merge branch 'feature/postgres-migrations' into feature/postgresql-sq…
suung Dec 14, 2025
121edcb
Fix Material Icons rendering for Streamlit components
suung Dec 15, 2025
ee1a15f
Fix linting issues for CI
suung Dec 15, 2025
920506e
Merge feature/sidebar-navigation-and-footer into feature/postgresql-s…
suung Dec 16, 2025
92e846d
Fix test dependencies and PostgreSQL file storage persistence
suung Dec 18, 2025
32f9f7b
Fix black formatting in alembic migration
suung Feb 19, 2026
878def2
Add Render and Streamlit deploy buttons
suung Feb 19, 2026
6b3f34a
Add node_modules to .gitignore
suung Feb 19, 2026
c8146f9
Add black/isort excludes and flake8 config
suung Feb 19, 2026
67349ab
Merge postgres branch into deploy
suung Feb 19, 2026
2691483
Add Vercel and Heroku deploy buttons and app.json
suung Feb 19, 2026
230b4c8
Sort deploy badges by license and fix Heroku build
suung Feb 19, 2026
029f333
Improve Deploy section and add one-click intro
suung Feb 20, 2026
5901e0a
Add Postgres and migrations to Render blueprint
suung Feb 20, 2026
822bd71
Fix deploy buttons
suung Feb 20, 2026
fb204fa
Bump faiss-cpu to 1.12.0 for Render build
suung Feb 20, 2026
897a01f
Pin Python 3.12 for Render build
suung Feb 20, 2026
384d28e
Add OpenRouter OAuth for one-click AI access without credit card
suung Feb 20, 2026
d524dba
Use RENDER_EXTERNAL_URL for OpenRouter callback (auto on Render)
suung Feb 20, 2026
d6d1990
Merge origin/main into issue/29-openrouter-setup
suung Mar 13, 2026
a7970d5
Fix black formatting and remove unused code in OpenRouter module
suung Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
61 changes: 39 additions & 22 deletions report_analyst/core/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
36 changes: 34 additions & 2 deletions report_analyst/core/llm_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down
136 changes: 136 additions & 0 deletions report_analyst/core/openrouter_oauth.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading