Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ Free AI APIs have rate limits, downtime, and variable speed. SuperBrain solves t
| **Groq** | `GROQ_API_KEY` | Fastest inference — free tier at [console.groq.com](https://console.groq.com) |
| **Google Gemini** | `GEMINI_API_KEY` | Most generous free tier at [aistudio.google.com](https://aistudio.google.com) |
| **OpenRouter** | `OPENROUTER_API_KEY` | Free model router at [openrouter.ai](https://openrouter.ai) |
| **Requesty** | `REQUESTY_API_KEY` | Optional paid gateway at [requesty.ai](https://app.requesty.ai/api-keys). Not part of the fallback order: only used when a key is set, with `REQUESTY_MODEL` (default `openai/gpt-4o-mini`) tried first and the chain above as backup. `REQUESTY_BASE_URL=https://router.eu.requesty.ai/v1` routes through the EU region |
| **Ollama** | *(no key needed)* | Local inference — `start.py` guides setup · recommended model: `qwen3-vl:4b` |

> **Tip:** You don't need all providers — the router falls back automatically. Start with at least **Gemini** (most generous free tier). Ollama serves as the fully offline last resort.
Expand Down Expand Up @@ -338,6 +339,7 @@ Edit `.env` and set at least:
- `GEMINI_API_KEY` (recommended)
- `GROQ_API_KEY` (optional)
- `OPENROUTER_API_KEY` (optional)
- `REQUESTY_API_KEY` and `REQUESTY_MODEL` (optional)

Then run:

Expand Down
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ DATABASE_TIMEOUT=30
GROQ_API_KEY=
GEMINI_API_KEY=
OPENROUTER_API_KEY=
# Optional: Requesty (https://app.requesty.ai/api-keys), used only when set
REQUESTY_API_KEY=
# REQUESTY_MODEL=openai/gpt-4o-mini
# REQUESTY_BASE_URL=https://router.eu.requesty.ai/v1

# ──── Whisper Configuration ────
# Options: tiny, base, small, medium, large
Expand Down
15 changes: 12 additions & 3 deletions backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1754,6 +1754,11 @@ async def get_ai_providers(token: str = Depends(verify_token)):
"has_key": providers.get("openrouter", False),
"key_hint": "sk-or-..." if providers.get("openrouter") else None
},
"requesty": {
"name": "Requesty",
"has_key": providers.get("requesty", False),
"key_hint": "rqsty-..." if providers.get("requesty") else None
},
"ollama": {
"name": "Ollama (Local)",
"has_key": providers.get("ollama", False),
Expand All @@ -1770,12 +1775,12 @@ async def set_ai_provider_key(
"""
Set an API key for an AI provider.
- Requires API authentication
- provider: groq, gemini, or openrouter
- provider: groq, gemini, openrouter, or requesty
"""
from core.model_router import get_router
import httpx

valid_providers = ["groq", "gemini", "openrouter"]
valid_providers = ["groq", "gemini", "openrouter", "requesty"]
provider_slug = data.provider.lower()
if provider_slug not in valid_providers:
raise HTTPException(
Expand All @@ -1801,6 +1806,10 @@ async def set_ai_provider_key(
resp = await client.get("https://openrouter.ai/api/v1/auth/key", headers={"Authorization": f"Bearer {data.api_key.strip()}"})
if resp.status_code != 200:
raise HTTPException(status_code=401, detail="Invalid OpenRouter API Key")
elif provider_slug == "requesty":
resp = await client.get("https://router.requesty.ai/v1/models", headers={"Authorization": f"Bearer {data.api_key.strip()}"})
if resp.status_code != 200:
raise HTTPException(status_code=401, detail="Invalid Requesty API Key")
except httpx.RequestError as e:
raise HTTPException(status_code=502, detail=f"Network error validating API key: {e}")

Expand All @@ -1824,7 +1833,7 @@ async def delete_ai_provider_key(
"""
from core.model_router import get_router

valid_providers = ["groq", "gemini", "openrouter"]
valid_providers = ["groq", "gemini", "openrouter", "requesty"]
if provider.lower() not in valid_providers:
raise HTTPException(
status_code=400,
Expand Down
3 changes: 3 additions & 0 deletions backend/config/.api_keys.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# Groq → https://console.groq.com/keys
# Gemini → https://aistudio.google.com/apikey
# OpenRouter → https://openrouter.ai/keys
# Requesty → https://app.requesty.ai/api-keys (optional, used only when set)
#
# You don't need all three — the router falls back automatically.
# Recommended: add at least Gemini (most generous free tier).
Expand All @@ -14,6 +15,8 @@
GROQ_API_KEY=gsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GEMINI_API_KEY=AIzaxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
OPENROUTER_API_KEY=sk-or-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# REQUESTY_API_KEY=rqsty-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# REQUESTY_MODEL=openai/gpt-4o-mini

# Instagram account used for downloading posts (instagrapi / Private API).
# Use a secondary/burner account — not your main personal account.
Expand Down
4 changes: 4 additions & 0 deletions backend/config_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ class Settings:
# OpenRouter API (for model fallback)
OPENROUTER_API_KEY: Optional[str] = os.getenv("OPENROUTER_API_KEY")

# Requesty API (only used when a key is set, model via REQUESTY_MODEL)
REQUESTY_API_KEY: Optional[str] = os.getenv("REQUESTY_API_KEY")
REQUESTY_MODEL: str = os.getenv("REQUESTY_MODEL", "openai/gpt-4o-mini")

# Whisper Model Configuration
WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "base")
WHISPER_USE_CLOUD: bool = os.getenv("WHISPER_USE_CLOUD", "true").lower() == "true"
Expand Down
100 changes: 97 additions & 3 deletions backend/core/model_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
GROQ_API_KEY=gsk_...
GEMINI_API_KEY=AIza...
OPENROUTER_API_KEY=sk-or-...
REQUESTY_API_KEY=rqsty-...
REQUESTY_MODEL=openai/gpt-4o-mini (optional)

Requesty is not part of the priority order. It is only used when you add a
REQUESTY_API_KEY: requests then go to REQUESTY_MODEL first, and the normal
chain above is used if that call fails.

Performance state persisted to backend/model_rankings.json (rankings survive restarts).
Dynamic model list cached in backend/openrouter_free_models.json (refreshed every 6 h).
Expand Down Expand Up @@ -56,6 +62,11 @@
OPENROUTER_FREE_CACHE_HOURS = 6 # re-fetch every 6 h
OPENROUTER_API_MODELS_URL = "https://openrouter.ai/api/v1/models"

# Requesty (https://requesty.ai): OpenAI compatible gateway, only used when the user adds a key.
# Set REQUESTY_BASE_URL=https://router.eu.requesty.ai/v1 to route through the EU region.
REQUESTY_BASE_URL = os.environ.get("REQUESTY_BASE_URL", "https://router.requesty.ai/v1").rstrip("/")
REQUESTY_DEFAULT_MODEL = "openai/gpt-4o-mini"

# Trusted providers — affects scoring weight for dynamic discovery (FreeRide)
TRUSTED_PROVIDERS = [
"google", "meta-llama", "mistralai", "deepseek",
Expand Down Expand Up @@ -522,7 +533,7 @@ def __init__(self):

def _load_api_keys(self):
"""Load API keys from environment and .api_keys file."""
for k in ("GROQ_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY"):
for k in ("GROQ_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY", "REQUESTY_API_KEY", "REQUESTY_MODEL"):
v = os.environ.get(k)
if v:
self._api_keys[k] = v
Expand Down Expand Up @@ -550,13 +561,14 @@ def get_available_providers(self) -> Dict[str, bool]:
"groq": bool(self._key("GROQ_API_KEY")),
"gemini": bool(self._key("GEMINI_API_KEY")),
"openrouter": bool(self._key("OPENROUTER_API_KEY")),
"requesty": bool(self._key("REQUESTY_API_KEY")),
"ollama": True, # Always available
}

def set_api_key(self, provider: str, api_key: str) -> bool:
"""Set an API key for a provider and persist to file."""
key_name = f"{provider.upper()}_API_KEY"
valid_providers = ["GROQ_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY"]
valid_providers = ["GROQ_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY", "REQUESTY_API_KEY"]

if key_name not in valid_providers:
return False
Expand All @@ -571,7 +583,7 @@ def set_api_key(self, provider: str, api_key: str) -> bool:
def delete_api_key(self, provider: str) -> bool:
"""Delete an API key for a provider."""
key_name = f"{provider.upper()}_API_KEY"
valid_providers = ["GROQ_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY"]
valid_providers = ["GROQ_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY", "REQUESTY_API_KEY"]

if key_name not in valid_providers:
return False
Expand Down Expand Up @@ -1048,6 +1060,78 @@ def _openrouter_vision(self, model_id: str, prompt: str, images_b64: List[str])
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"].strip()

def _requesty_text(self, model_id: str, prompt: str) -> str:
import requests
resp = requests.post(
f"{REQUESTY_BASE_URL}/chat/completions",
headers={
"Authorization": f"Bearer {self._key('REQUESTY_API_KEY')}",
"HTTP-Referer": "https://github.com/superbrain",
"X-Title": "SuperBrain",
"Content-Type": "application/json",
},
json={
"model": model_id,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 800,
"temperature": 0.7,
},
timeout=60,
)
if resp.status_code == 429:
raise Exception(f"429 rate limit: {resp.text[:200]}")
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"].strip()

def _requesty_vision(self, model_id: str, prompt: str, images_b64: List[str]) -> str:
import requests
content: List[Dict] = []
for b64 in images_b64:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
})
content.append({"type": "text", "text": prompt})
resp = requests.post(
f"{REQUESTY_BASE_URL}/chat/completions",
headers={
"Authorization": f"Bearer {self._key('REQUESTY_API_KEY')}",
"HTTP-Referer": "https://github.com/superbrain",
"X-Title": "SuperBrain",
"Content-Type": "application/json",
},
json={
"model": model_id,
"messages": [{"role": "user", "content": content}],
"max_tokens": 800,
"temperature": 0.7,
},
timeout=90,
)
if resp.status_code == 429:
raise Exception(f"429 rate limit: {resp.text[:200]}")
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"].strip()

def _try_requesty(self, call, *args) -> Optional[str]:
"""
Requesty is opt-in: it is only called when the user has added a REQUESTY_API_KEY,
and it never enters the ranked fallback order. Returns None if not configured or
if the call fails, so the caller continues with the normal chain.
"""
if not self._key("REQUESTY_API_KEY"):
return None
model_id = self._key("REQUESTY_MODEL") or REQUESTY_DEFAULT_MODEL
print(f" 🤖 [REQUESTY] {model_id} ...", flush=True)
t0 = time.time()
try:
result = call(model_id, *args)
except Exception as e:
print(f" ✗ Requesty failed ({type(e).__name__}), using fallback chain …", flush=True)
return None
print(f" ✓ {time.time() - t0:.1f}s", flush=True)
return result

def _ollama_text(self, model_id: str, prompt: str) -> str:
import ollama
r = ollama.generate(
Expand Down Expand Up @@ -1075,6 +1159,10 @@ def generate_text(self, prompt: str) -> str:
Falls back through the ranked list until one succeeds.
Raises RuntimeError if all fail.
"""
result = self._try_requesty(self._requesty_text, prompt)
if result is not None:
return result

ranked = self._ranked_models("text")
if not ranked:
raise RuntimeError(
Expand Down Expand Up @@ -1122,6 +1210,10 @@ def analyze_images(self, prompt: str, images_b64: List[str]) -> str:
Falls back through the ranked list until one succeeds.
Raises RuntimeError if all fail.
"""
result = self._try_requesty(self._requesty_vision, prompt, images_b64)
if result is not None:
return result

ranked = self._ranked_models("vision")
if not ranked:
raise RuntimeError(
Expand Down Expand Up @@ -1224,6 +1316,8 @@ def _print_startup_status(self):
parts.append("Groq ✓" if self._key("GROQ_API_KEY") else "Groq ✗")
parts.append("Gemini ✓" if self._key("GEMINI_API_KEY") else "Gemini ✗")
parts.append("OpenRouter ✓" if self._key("OPENROUTER_API_KEY") else "OpenRouter ✗")
if self._key("REQUESTY_API_KEY"):
parts.append(f"Requesty ✓ ({self._key('REQUESTY_MODEL') or REQUESTY_DEFAULT_MODEL})")
parts.append("Ollama (fallback)")
print(f"🌐 Model Router initialised: {' | '.join(parts)}")

Expand Down
3 changes: 3 additions & 0 deletions backend/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ services:
GEMINI_API_KEY: ${GEMINI_API_KEY}
GOOGLE_API_KEY: ${GOOGLE_API_KEY}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
REQUESTY_API_KEY: ${REQUESTY_API_KEY}
REQUESTY_MODEL: ${REQUESTY_MODEL:-openai/gpt-4o-mini}
REQUESTY_BASE_URL: ${REQUESTY_BASE_URL:-https://router.requesty.ai/v1}

# Instagram credentials
INSTAGRAM_USERNAME: ${INSTAGRAM_USERNAME}
Expand Down
2 changes: 1 addition & 1 deletion backend/reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def _remove_dir(path: Path, label: str):
# ── Reset actions ─────────────────────────────────────────────────────────────
def reset_api_keys():
h1("Reset — API Keys")
warn("This removes ALL keys: Gemini / Groq / OpenRouter and Instagram credentials.")
warn("This removes ALL keys: Gemini / Groq / OpenRouter / Requesty and Instagram credentials.")
if not ask_yn("Continue?", default=False):
info("Skipped.")
return
Expand Down
4 changes: 2 additions & 2 deletions backend/start-docker-prod.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ fi
# ──── Check required API keys ────
source .env

if [ -z "$GROQ_API_KEY" ] && [ -z "$GEMINI_API_KEY" ] && [ -z "$GOOGLE_API_KEY" ] && [ -z "$OPENROUTER_API_KEY" ]; then
if [ -z "$GROQ_API_KEY" ] && [ -z "$GEMINI_API_KEY" ] && [ -z "$GOOGLE_API_KEY" ] && [ -z "$OPENROUTER_API_KEY" ] && [ -z "$REQUESTY_API_KEY" ]; then
echo -e "${YELLOW}⚠ Warning: No AI provider API keys configured${NC}"
echo -e "${YELLOW} Set at least one of: GROQ_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEY${NC}"
echo -e "${YELLOW} Set at least one of: GROQ_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEY, REQUESTY_API_KEY${NC}"
fi

# ──── Create necessary directories ────
Expand Down
30 changes: 28 additions & 2 deletions backend/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,22 @@ def _validate_openrouter(key: str):
except Exception as e:
return None, f"could not verify ({str(e)[:70]})"

def _validate_requesty(key: str):
try:
import urllib.request as _r, urllib.error as _e
req = _r.Request("https://router.requesty.ai/v1/models",
headers={"Authorization": f"Bearer {key}",
"Accept": "application/json",
"User-Agent": "Mozilla/5.0"})
with _r.urlopen(req, timeout=8):
return True, "key valid"
except _e.HTTPError as e:
if e.code in (401, 403):
return False, f"invalid key ({e.code})"
return None, f"could not verify ({e.code} {e.reason})"
except Exception as e:
return None, f"could not verify ({str(e)[:70]})"

def _check_and_report(name: str, key: str, validator) -> str:
"""Validate `key`, print result inline, return the key unchanged."""
if not key:
Expand Down Expand Up @@ -342,6 +358,9 @@ def setup_api_keys():
Groq → {CYAN}https://console.groq.com/keys{RESET}
OpenRouter → {CYAN}https://openrouter.ai/keys{RESET}

Optional paid gateway (used only if you add a key, not part of the fallback order):
Requesty → {CYAN}https://app.requesty.ai/api-keys{RESET}

Press {BOLD}Enter{RESET} to skip any key you don't have yet.
{DIM}Keys and passwords are visible as you paste — don't run setup in a screen share.{RESET}
""")
Expand All @@ -361,8 +380,13 @@ def setup_api_keys():
groq_k = _check_and_report("Groq", groq_k, _validate_groq)
openr = ask("OpenRouter API key", default=existing.get("OPENROUTER_API_KEY"), paste=True) or ""
openr = _check_and_report("OpenRouter", openr, _validate_openrouter)
requesty = ask("Requesty API key", default=existing.get("REQUESTY_API_KEY"), paste=True) or ""
requesty = _check_and_report("Requesty", requesty, _validate_requesty)
requesty_model = ""
if requesty:
requesty_model = ask("Requesty model", default=existing.get("REQUESTY_MODEL") or "openai/gpt-4o-mini") or ""

if not any([gemini, groq_k, openr]):
if not any([gemini, groq_k, openr, requesty]):
warn("No AI keys entered. SuperBrain will still work but can only use")
warn("local Ollama models (configured in the next step).")

Expand Down Expand Up @@ -395,6 +419,8 @@ def setup_api_keys():
f"GEMINI_API_KEY={gemini}\n",
f"GROQ_API_KEY={groq_k}\n",
f"OPENROUTER_API_KEY={openr}\n",
f"REQUESTY_API_KEY={requesty}\n",
f"REQUESTY_MODEL={requesty_model}\n",
"\n",
f"INSTAGRAM_USERNAME={ig_user}\n",
f"INSTAGRAM_PASSWORD={ig_pass}\n",
Expand All @@ -417,7 +443,7 @@ def setup_ollama():
return

keys = _load_saved_api_keys()
has_cloud_key = any(keys.get(k) for k in ("GEMINI_API_KEY", "GROQ_API_KEY", "OPENROUTER_API_KEY"))
has_cloud_key = any(keys.get(k) for k in ("GEMINI_API_KEY", "GROQ_API_KEY", "OPENROUTER_API_KEY", "REQUESTY_API_KEY"))

print(f"""
Ollama runs AI models {BOLD}locally on your machine{RESET} — no internet or API
Expand Down
Loading