+Deploy **Odysseus** on Render in one click. Get a self-hosted AI workspace — chat, agents, deep research, documents, email, notes, and calendar — running on your own instance with your own API keys.
-
-
-
+[](https://render.com/deploy?repo=https://github.com/Ho1yShif/odysseus)
+
+https://github.com/user-attachments/assets/53277926-7b65-4687-8a0a-42878bd549a8
-
+
----
+## What you get
-## Quick Start
+This Blueprint provisions three services on Render:
-> `dev` is the default branch and gets the newest changes first. Use [`main`](https://github.com/odysseus-dev/odysseus/tree/main) if you want the more curated branch.
+| Service | What it is |
+|---------|------------|
+| `odysseus` | The web app (chat, agents, research, documents, email, notes, calendar). Persistent disk at `/app/data`. |
+| `odysseus-searxng` | Bundled [SearXNG](https://github.com/searxng/searxng) for private web search — powers Deep Research with no extra key. |
+| `odysseus-chromadb` | Bundled [ChromaDB](https://www.trychroma.com/) vector store for RAG and semantic memory. |
-```bash
-git clone https://github.com/odysseus-dev/odysseus.git
-cd odysseus
-cp .env.example .env
-docker compose up -d --build
+Auth is on by default (`AUTH_ENABLED=true`, secure cookies, a generated admin password), and both helper services are private — only the web app is exposed.
+
+## Architecture
+
+Only `odysseus` is public. It reaches the two helper services over Render's private network, and calls out to your LLM and (optional) search providers with your own API keys.
+
+```
+ ┌─────────────────────────────┐
+ Internet ───► │ odysseus (public web app) │
+ │ disk: /app/data │
+ └──────┬───────────────┬──────┘
+ │ private │ private
+ ┌──────▼──────┐ ┌─────▼────────────┐
+ │ searxng │ │ chromadb │
+ │ web search │ │ vector store │
+ └──────┬──────┘ └──────────────────┘
+ │
+ ┌─────────┴──────────────────────────────┐
+ │ external APIs (your keys) │
+ │ OpenAI · Brave · Tavily · Serper · … │
+ └────────────────────────────────────────┘
```
-Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`.
+> This is the **hosted** build. Local-model serving (Cookbook/vLLM/llama.cpp), GPU inference, image upscaling, and host-Docker features from the [upstream project](https://github.com/odysseus-dev/odysseus) don't run on Render and are omitted here; Odysseus uses cloud LLM APIs instead. For the full self-hosted feature set, see the [upstream repo](https://github.com/odysseus-dev/odysseus).
+
+## Deploy
+
+1. Click **Deploy to Render** above.
+2. Fill in the API keys you want (see below) in the deploy form, then apply the Blueprint.
+3. Wait for all three services to go live.
+
+### Environment variables
+
+Set these as secrets in the deploy form. All are optional per feature — you only need the keys for the features you'll use.
+
+To restrict `OPENAI_API_KEY`, a key with only the **Chat completions** (`/v1/chat/completions`) permission is enough — embeddings run locally (fastembed) and no other OpenAI endpoint is used. Set everything else to **None**.
+
+| Variable | Needed for | Where to get it |
+|----------|-----------|-----------------|
+| `OPENAI_API_KEY` | Chat, agents, research (LLM calls) | [platform.openai.com](https://platform.openai.com/api-keys) |
+| `OPENAI_DEFAULT_MODEL` | Model seeded as the default chat on first boot (default `gpt-5.6-sol`; change here or in the app) — not a secret | — |
+| `DATA_BRAVE_API_KEY` | Brave web search (optional — SearXNG is bundled) | [brave.com/search/api](https://brave.com/search/api/) |
+| `TAVILY_API_KEY` | Tavily search provider (optional) | [tavily.com](https://tavily.com/) |
+| `SERPER_API_KEY` | Serper search provider (optional) | [serper.dev](https://serper.dev/) |
+| `GOOGLE_API_KEY` + `GOOGLE_PSE_CX` | Google Programmable Search (optional) | [Google Cloud](https://developers.google.com/custom-search) |
+| `HF_TOKEN` | Gated Hugging Face models (optional) | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) |
+
+Set automatically — no action needed: `ODYSSEUS_ADMIN_PASSWORD` (generated), `SEARXNG_SECRET` (generated), plus the internal service wiring.
+
+**Advanced — `ALLOWED_ORIGINS` (CORS):** by default the app locks CORS to its own Render URL (it reads `RENDER_EXTERNAL_URL` automatically), so you don't need to set anything. Only set `ALLOWED_ORIGINS` if you serve the app from a **custom domain** or need to allow **additional origins** — provide a comma-separated list of the full origins (e.g. `https://app.example.com,https://www.example.com`).
+
+### Using the app
+
+1. Open the `odysseus` service URL once it's live.
+2. Log in as **`admin`**. Your admin password is **created for you automatically** at deploy time — you don't set one. Find it in the Render Dashboard → the `odysseus` service → **Environment** → `ODYSSEUS_ADMIN_PASSWORD` (a strong, randomly generated 256-bit value). Copy it to log in, then change it from the app after first login. It's never printed to the logs.
+3. To have chat work out of the gate, set `OPENAI_API_KEY` on the `odysseus` web service's env vars **before first boot**. On startup the deploy seeds an OpenAI endpoint from that key (default model `OPENAI_DEFAULT_MODEL`), so you can open **Chat**, send a message, and get a reply with nothing to wire up in the model picker.
+4. Try **Deep Research**: click **Deep Research** in the tools menu on the left-hand side to open its modal, enter your question, and run it. It searches the web through the bundled SearXNG (no extra key) and generates a sourced report.
+
+> Want to let strangers try the app without an admin password? See **[Demo mode](#demo-mode)** below — a public, no-signup chat surface you can turn on with `DEMO=true`. It's **off by default**, so a fresh deploy stays fully authenticated.
+
+## Demo mode
+
+`DEMO=true` runs a **public, no-signup, locked-down chat demo** on **your** OpenAI key — so anyone with the URL can try the chat without logging in. It is **off by default** (`DEMO=false`): a fresh fork or deploy gets the full authenticated app, unchanged. Only a deliberate `DEMO=true` turns it on.
+
+**How it works.** With the flag on, the login gate opens *for chat only*. Each visitor gets an isolated, ephemeral demo session (an unguessable per-visitor cookie → a synthetic owner) under a least-privilege profile. Everything else — settings, admin, integrations, and every other API route — still requires the admin login exactly as before. The admin account and its password are untouched.
+
+**What the demo can and can't do:**
+
+| Capability | In demo |
+|---|---|
+| Chat (pinned cheap model, capped output) | ✅ on |
+| Shell / code / file tools | ❌ off |
+| File upload & personal-doc RAG | ❌ off |
+| Image generation, TTS / STT | ❌ off |
+| Deep research | ❌ off (expensive per run) |
+| Email, MCP servers, cookbook, task scheduler | ❌ off |
+| Memory writes, API-token minting | ❌ off |
+| Settings / admin / integrations | ❌ off (admin login still required) |
-Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](docs/setup.md).
+**Abuse & cost limits** (demo-only; the demo spends **your** key, so watch your [OpenAI usage console](https://platform.openai.com/usage)):
-## Features
+| Variable | Default | What it caps |
+|---|---|---|
+| `DEMO_MODEL` | `gpt-5.6-luna` | the pinned (cheap) chat model |
+| `DEMO_MAX_OUTPUT_TOKENS` | `512` | output tokens per reply |
+| `DEMO_RATE_LIMIT_PER_MINUTE` | `10` | chat sends per minute, per client IP |
+| `DEMO_MAX_MESSAGES_PER_SESSION` | `30` | total messages per visitor cookie session (UX friction) |
+| `DEMO_MAX_MESSAGES_PER_IP_PER_DAY` | `200` | hard per-IP daily message ceiling (the real backstop) |
-- **Chat + Agents** — local/API models, tools, MCP, files, shell, skills, and memory.
-- **Cookbook** — hardware-aware model recommendations, downloads, and serving.
-- **Deep Research** — multi-step web research with source reading and report generation.
-- **Compare** — blind side-by-side model testing and synthesis.
-- **Documents** — writing-first editor with AI edits, suggestions, Markdown, HTML, CSV, and syntax highlighting.
-- **Email** — IMAP/SMTP inbox with triage, tags, summaries, reminders, and reply drafts.
-- **Notes, Tasks + Calendar** — reminders, todos, scheduled agent tasks, and CalDAV sync.
-- **Extras** — gallery/image editor, themes, uploads, web search, presets, sessions, and 2FA.
+Raise or lower these in the deploy form / `render.yaml`. Set a limit to `0` to disable that one dimension; an unset variable falls back to the default (it **never** means "unlimited"). When a visitor hits a cap they get a friendly "deploy your own to keep going" reply — never an error.
-## Demo
+The rate limit and the per-IP daily ceiling are keyed on the **trusted client IP** — the entry Render's proxy attests on `X-Forwarded-For`, read from the right (`TRUSTED_PROXY_HOPS` hops in, default `1`), never the spoofable leftmost value. So clearing cookies or churning the demo session **can't** reset them. The per-session cap is cookie-based, so it's UX friction only; the per-IP ceiling is the volume backstop. Visitors behind one NAT/IP share a bucket — that errs toward more limiting, which is what you want for a cost guard.
-A full hover-to-play tour lives on the landing page: [`docs/index.html`](docs/index.html).
+> `TRUSTED_PROXY_HOPS` is app-wide, not demo-only: the auth login/signup/setup rate limiters key on the same trusted IP. On first traffic the app logs a one-time `[trusted-ip] X-Forwarded-For sample` line — check it once on your deploy to confirm `1` hop resolves your real client IP (retune if you front the service with extra proxies).
+>
+> **Deploying off Render?** Set `TRUSTED_PROXY_HOPS` to match your topology: `0` if the service is directly internet-facing with **no** proxy in front (the whole `X-Forwarded-For` header is then attacker-supplied, so it's ignored and the limiters key on the real TCP peer), or `N` for `N` trusted proxies. Leaving the default `1` on a proxy-less deploy lets a client spoof `X-Forwarded-For` and bypass every IP-keyed limit.
-## Contributing
+> **Your real spend ceiling is the OpenAI limit, not these counters.** The caps above bound the burn *rate*; they reduce how fast the key can be spent, they don't cap total dollars. The one true per-call ceiling is `DEMO_MAX_OUTPUT_TOKENS`. For a hard dollar cap, set a [monthly usage limit on your OpenAI project](https://platform.openai.com/settings/organization/limits) — that's what protects the bill if the demo URL is discovered or abused.
-Help is welcome. The best entry points are fresh-install testing, provider setup bugs, mobile/editor polish, docs, and small focused refactors. See [CONTRIBUTING.md](CONTRIBUTING.md) and [ROADMAP.md](ROADMAP.md).
+**Session isolation & privacy.** Visitors can't see each other's chats (each is scoped to its own synthetic owner), and demo history is **ephemeral** — it lives in memory only and is never written to the deployer's disk. If you host a public demo URL, add a visible "public demo, may reset — don't submit anything sensitive" notice.
-## Security
+### Scaling for heavy workloads
-Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly. Deployment details are in the [setup guide](docs/setup.md#security-notes).
+The Blueprint defaults the web service to `standard` (2 GB). Odysseus can be resource-hungry under heavy use — large deep-research runs, big documents, sizable embedding jobs, or many concurrent sessions. For those workloads, give the instance more resources: in the Render Dashboard, open the `odysseus` service → **Settings → Instance Type** and pick a larger plan (and bump `odysseus-chromadb` too if your vector store grows). You can downgrade later if the smaller plan proves sufficient.
-## Star History
+## Learn more
-
-
-
-
-
-
-
+Full documentation, the complete self-hosted feature set, and contributing guidelines live in the upstream project: [odysseus-dev/odysseus](https://github.com/odysseus-dev/odysseus).
## License
-AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md).
+AGPL-3.0-or-later — see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md).
diff --git a/app.py b/app.py
index 8363ba4e9..ba7103644 100644
--- a/app.py
+++ b/app.py
@@ -126,7 +126,12 @@ def register_static_mime_types() -> None:
# ========= CORS =========
CORS_ALLOW_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]
-allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost,http://127.0.0.1").split(",")
+# Honor ALLOWED_ORIGINS when set. Otherwise, on Render, default to this
+# service's own public origin (RENDER_EXTERNAL_URL is injected automatically) so
+# a fresh hosted deploy is locked to same-origin with zero configuration; fall
+# back to localhost for local dev. Never a wildcard — allow_credentials is on.
+_default_origins = os.getenv("RENDER_EXTERNAL_URL") or "http://localhost,http://127.0.0.1"
+allowed_origins = [o.strip() for o in os.getenv("ALLOWED_ORIGINS", _default_origins).split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
@@ -253,6 +258,12 @@ async def dispatch(self, request, call_next):
if LOCALHOST_BYPASS:
logger.warning("LOCALHOST_BYPASS is enabled, loopback requests bypass authentication. Do not expose this instance to a network.")
+# DEMO mode: opt-in, default off. When on, AuthMiddleware mints a per-visitor
+# locked-down demo session for unauthenticated visitors on the demo route
+# whitelist only; everything else still 302→/login or 401. Forks leave DEMO
+# unset and get the full authenticated app. See src/demo.py.
+from src import demo as _demo
+
if AUTH_ENABLED:
AUTH_EXEMPT_EXACT = {
"/api/auth/setup",
@@ -266,6 +277,11 @@ async def dispatch(self, request, call_next):
"/api/health",
"/api/version",
"/login",
+ # Browsers auto-request the domain-root /favicon.ico for tabs and
+ # bookmarks. Without this exemption AuthMiddleware 302→/login, the
+ # browser gets HTML instead of an icon, and falls back to a stale
+ # cached favicon. See the /favicon.ico route below.
+ "/favicon.ico",
}
AUTH_EXEMPT_PREFIXES = ["/static"]
# Dynamic paths whose own handler proves identity via a path-embedded
@@ -458,15 +474,30 @@ def _do():
# --- Cookie-based session auth ---
token = request.cookies.get(SESSION_COOKIE)
- if not auth_manager.validate_token(token):
- if path.startswith("/api/"):
- return JSONResponse(status_code=401, content={"error": "Not authenticated"})
- return RedirectResponse(url="/login", status_code=302)
-
- # Attach current username to request state for downstream routes
- request.state.current_user = auth_manager.get_username_for_token(token)
- request.state.api_token = False
- return await call_next(request)
+ if auth_manager.validate_token(token):
+ # Attach current username to request state for downstream routes
+ request.state.current_user = auth_manager.get_username_for_token(token)
+ request.state.api_token = False
+ return await call_next(request)
+
+ # --- Demo path (opt-in, default off) ---
+ # Reached only for unauthenticated visitors AFTER the is_configured
+ # check, so first-run setup/login is unaffected. Mints a per-visitor
+ # locked-down synthetic owner, but ONLY on the demo route whitelist;
+ # everything else still 302→/login or 401 below.
+ if _demo.DEMO_MODE and _demo.is_demo_allowed(request.method, path):
+ owner, new_cookie = _demo.resolve_demo_owner(request)
+ request.state.current_user = owner
+ request.state.api_token = False
+ request.state.is_demo = True
+ response = await call_next(request)
+ if new_cookie:
+ _demo.set_demo_cookie(response, new_cookie)
+ return response
+
+ if path.startswith("/api/"):
+ return JSONResponse(status_code=401, content={"error": "Not authenticated"})
+ return RedirectResponse(url="/login", status_code=302)
app.add_middleware(AuthMiddleware)
logger.info("Auth middleware enabled (AUTH_ENABLED=true)")
@@ -917,6 +948,17 @@ async def serve_backgrounds(request: Request):
"""Sandbox page for prototyping background effects. No auth required."""
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
+@app.get("/favicon.ico")
+async def serve_favicon():
+ """Serve the favicon at the domain root. Browsers request /favicon.ico
+ automatically (independent of the page's ); serving it
+ here — rather than letting auth redirect it to /login — stops browsers
+ from falling back to a stale cached icon."""
+ return FileResponse(
+ abs_join(STATIC_DIR, "favicon.ico"),
+ media_type="image/x-icon",
+ )
+
@app.get("/login")
async def serve_login(request: Request):
if not AUTH_ENABLED:
@@ -1008,6 +1050,9 @@ async def _lifespan(app):
async def _startup_event():
global upload_cleanup_task
logger.info("Application starting up...")
+ # Announce which mode booted (normal vs. DEMO) so a misconfigured deploy is
+ # obvious in the logs. Inert unless DEMO=true.
+ _demo.log_startup_mode(logger)
webhook_manager.set_loop(asyncio.get_running_loop())
# Wipe any leftover incognito sessions from previous process — they're
# ephemeral by design and must not survive a restart.
@@ -1016,12 +1061,24 @@ async def _startup_event():
_db = _SL()
try:
_ghosts = _db.query(_DbSess).filter(_DbSess.name.in_(("Nobody", "Incognito"))).all()
+ # Demo sessions are ephemeral too: their chat history is never
+ # persisted, but the lightweight metadata rows accumulate one per
+ # visitor page-load. Wipe them on boot so a long-running public demo
+ # can't grow the sessions table without bound (bounded by the 1-day
+ # demo cookie + each restart). Owner-scoped, so no real user data.
+ #
+ # Gated on DEMO_MODE for the same reason src.demo.is_demo_owner is:
+ # on a normal deploy a `demo-`-prefixed username is an ordinary user
+ # (usernames are only lowercased, so `demo-team` is registerable) and
+ # purging their sessions + messages every boot would be data loss.
+ if _demo.DEMO_MODE:
+ _ghosts += _db.query(_DbSess).filter(_DbSess.owner.like("demo-%")).all()
for _g in _ghosts:
_db.query(_DbMsg).filter(_DbMsg.session_id == _g.id).delete()
_db.delete(_g)
if _ghosts:
_db.commit()
- logger.info(f"Purged {len(_ghosts)} leftover incognito session(s)")
+ logger.info(f"Purged {len(_ghosts)} leftover ephemeral session(s)")
finally:
_db.close()
except Exception as e:
diff --git a/core/auth.py b/core/auth.py
index 4bc9a70dd..c746986ac 100644
--- a/core/auth.py
+++ b/core/auth.py
@@ -18,9 +18,13 @@
logger = logging.getLogger(__name__)
-
from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402
from core.middleware import INTERNAL_TOOL_USER # noqa: E402
+# src.demo is a leaf module (stdlib + src.rate_limiter), so this is a plain
+# module-top import — no cycle to dodge. Imported as a module, not by name, so
+# DEMO_PRIVILEGES is read through it: a `from ... import DEMO_PRIVILEGES` would
+# bind the dict object and go stale if the module is ever reloaded.
+from src import demo as _demo # noqa: E402
DEFAULT_PRIVILEGES = {
"can_use_agent": True,
@@ -42,6 +46,7 @@
# Admins get everything
ADMIN_PRIVILEGES = {k: (True if isinstance(v, bool) else (0 if isinstance(v, int) else [])) for k, v in DEFAULT_PRIVILEGES.items()}
+
ADMIN_PRIVILEGES["allowed_models_restricted"] = False
# Admins must never be blocked from using models — the generic dict
# comprehension above flips every boolean default to True, which would be
@@ -384,6 +389,11 @@ def list_users(self) -> List[Dict[str, Any]]:
def get_privileges(self, username: str) -> Dict[str, Any]:
"""Get privileges for a user. Admins get all privileges."""
+ # Demo owners (demo-) get the least-privilege profile regardless of
+ # any stored config — they have no user row anyway. This is the single
+ # choke point that drives per-tool enforcement in chat_routes.
+ if _demo.is_demo_owner(username):
+ return {**DEFAULT_PRIVILEGES, **_demo.DEMO_PRIVILEGES}
user = self.users.get(username, {})
if user.get("is_admin"):
return dict(ADMIN_PRIVILEGES)
diff --git a/core/session_manager.py b/core/session_manager.py
index 6eb493e95..134cafe0b 100644
--- a/core/session_manager.py
+++ b/core/session_manager.py
@@ -9,6 +9,7 @@
"""
import json
+import threading
import uuid
import logging
from datetime import datetime, timezone, timedelta
@@ -17,6 +18,7 @@
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive
from .models import Session, ChatMessage
from src.attachment_refs import persistable_message_content
+from src import demo as _demo
from src.upload_handler import reserve_message_upload_references
# Re-export singleton accessors from models for convenience
@@ -233,9 +235,15 @@ def _persist_message(self, session_id: str, message: ChatMessage):
logger.warning("Dropping message for deleted session %s", session_id)
return
+ # Demo history is ephemeral: keep it in the in-memory SessionManager
+ # cache only and never write a stranger's chat to the deployer's disk.
+ owner = getattr(db_session, "owner", None)
+ if _demo.is_demo_owner(owner):
+ return
+
missing_upload_id = reserve_message_upload_references(
getattr(self, "upload_handler", None),
- getattr(db_session, "owner", None),
+ owner,
message.content,
message.metadata,
)
@@ -445,6 +453,14 @@ def sync_session_metadata(self, session_id: str) -> bool:
session.owner = getattr(db_session, "owner", None)
session.is_important = getattr(db_session, "is_important", False) or False
session.message_count = getattr(db_session, "message_count", session.message_count) or 0
+ # For demo owners, force the pinned model + endpoint + env OPENAI key
+ # authoritatively on every read. This overrides whatever the client
+ # sent to /api/session and is never persisted (the key stays
+ # env-only). Deliberately unguarded: if pinning ever fails, the outer
+ # handler returns False rather than letting the client's own endpoint
+ # values survive on a demo session.
+ if _demo.is_demo_owner(session.owner):
+ _demo.apply_demo_session_config(session)
return True
except Exception as e:
logger.error(f"Error syncing session metadata {session_id}: {e}")
diff --git a/docker/entrypoint.render.sh b/docker/entrypoint.render.sh
new file mode 100644
index 000000000..3d1e03b55
--- /dev/null
+++ b/docker/entrypoint.render.sh
@@ -0,0 +1,46 @@
+#!/bin/sh
+# Entrypoint for the hosted Render image (Dockerfile.render).
+#
+# On Render there is no bind-mounted host volume (the persistent disk at
+# /app/data is managed and already writable), so the PUID/PGID ownership-repair
+# dance in docker/entrypoint.sh is unnecessary here — we run as root and bind
+# the port Render injects via $PORT.
+set -eu
+
+# First-time setup is idempotent (creates auth.json/.env only if missing).
+# || true so a non-critical hiccup never blocks startup — matches entrypoint.sh.
+python /app/setup.py || true
+
+# Guard the one invariant a hosted deploy can't recover from on its own: with
+# auth enabled, an admin account must exist. setup.py swallows its own admin
+# errors and exits 0, so on failure the app would boot healthy (the "/" health
+# check passes) yet nobody could ever log in. Assert it here so a seeding
+# failure surfaces as a failed deploy instead. Reuses src.constants so the
+# path tracks ODYSSEUS_DATA_DIR exactly like the app resolves it.
+if [ "$(printf '%s' "${AUTH_ENABLED:-true}" | tr '[:upper:]' '[:lower:]')" != "false" ]; then
+ python - <<'PY'
+import json
+import sys
+
+from src.constants import AUTH_FILE
+
+try:
+ with open(AUTH_FILE, encoding="utf-8") as fh:
+ users = json.load(fh).get("users", {})
+except (OSError, ValueError) as exc:
+ sys.exit(f"[fatal] admin auth not initialized ({AUTH_FILE}): {exc}")
+
+if not users:
+ sys.exit(f"[fatal] admin auth file {AUTH_FILE} has no users — seeding failed")
+PY
+fi
+
+# Seed an OpenAI model endpoint from OPENAI_API_KEY on first boot so chat works
+# out of the box — setting the key alone leaves the model_endpoints table empty,
+# which surfaces as "No chat session active" in the composer. Idempotent (skips
+# if the key is unset or an OpenAI endpoint already exists) and non-fatal: a seed
+# hiccup must not block the deploy — the admin can always add the endpoint from
+# the UI.
+python /app/docker/seed_openai_endpoint.py || true
+
+exec uvicorn app:app --host 0.0.0.0 --port "${PORT:-7000}"
diff --git a/docker/searxng-render-entrypoint.sh b/docker/searxng-render-entrypoint.sh
new file mode 100644
index 000000000..f8d6d9b2d
--- /dev/null
+++ b/docker/searxng-render-entrypoint.sh
@@ -0,0 +1,28 @@
+#!/bin/sh
+# Render entrypoint for the bundled SearXNG image.
+#
+# Odysseus requires SearXNG's `json` output format, which the stock image does
+# not enable — so we ship config/searxng/settings.yml (baked in at build) and
+# render it into place on boot, substituting the secret_key. Mirrors the wrapper
+# in docker-compose.yml. Runs as root, writes /etc/searxng, then hands off to
+# SearXNG's own entrypoint (which drops privileges).
+set -eu
+
+if [ ! -s /etc/searxng/settings.yml ] || grep -q '__SEARXNG_SECRET__' /etc/searxng/settings.yml; then
+ secret="${SEARXNG_SECRET:-}"
+ if [ -z "$secret" ]; then
+ secret="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
+ fi
+ mkdir -p /etc/searxng
+ # Substitute via python (secret passed through the env, not interpolated) so a
+ # sed metachar in SEARXNG_SECRET (&, \, |) can't corrupt the rendered config.
+ SEARXNG_SECRET="$secret" python - <<'PY' > /etc/searxng/settings.yml
+import os
+
+with open("/usr/local/share/searxng-settings.yml.template", encoding="utf-8") as fh:
+ template = fh.read()
+print(template.replace("__SEARXNG_SECRET__", os.environ["SEARXNG_SECRET"]), end="")
+PY
+fi
+
+exec /usr/local/searxng/entrypoint.sh
diff --git a/docker/seed_openai_endpoint.py b/docker/seed_openai_endpoint.py
new file mode 100644
index 000000000..d8af1a90d
--- /dev/null
+++ b/docker/seed_openai_endpoint.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+"""Seed an OpenAI model endpoint on first boot of the hosted Render image.
+
+Setting ``OPENAI_API_KEY`` alone does NOT make chat work: the chat send path
+resolves the default model from the ``model_endpoints`` DB table (see
+``routes/model_routes.py::get_default_chat``), which is empty on a fresh deploy.
+With no endpoint, the composer shows "No chat session active" even though the key
+is set. This registers an OpenAI endpoint from ``OPENAI_API_KEY`` and marks it the
+global default so "open Chat, send a message, get a reply" works out of the box.
+
+Runs in the entrypoint before the app boots, in a separate process. It shares the
+app's Fernet key (a file under the persistent-disk data dir, see
+``src.secret_storage``), so ``api_key`` is encrypted at rest exactly as the app
+would encrypt it.
+
+Idempotent and safe to run every boot:
+- skips when ``OPENAI_API_KEY`` is unset,
+- skips when an ``api.openai.com`` endpoint already exists (the DB lives on the
+ persistent disk and survives redeploys), so it never duplicates or clobbers an
+ endpoint the admin later edits.
+
+The default model is pinned (not discovered via ``/v1/models``) so chat works even
+when the key is restricted to ``/v1/chat/completions`` — the probe would 403 and
+return no models otherwise. Override the pinned model with ``OPENAI_DEFAULT_MODEL``.
+"""
+
+import json
+import logging
+import os
+import sys
+import uuid
+from pathlib import Path
+
+# Run standalone from the entrypoint (python /app/docker/seed_openai_endpoint.py):
+# Python puts this file's dir (docker/) on sys.path, not the repo root, so the
+# core/ and src/ packages wouldn't import. Add the repo root (this file's parent
+# dir's parent) explicitly.
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+logging.basicConfig(level=logging.INFO, format="%(message)s")
+log = logging.getLogger("seed_openai_endpoint")
+
+DEFAULT_MODEL = "gpt-5.6-sol"
+
+
+def main() -> int:
+ api_key = (os.getenv("OPENAI_API_KEY") or "").strip()
+ if not api_key:
+ log.info("[seed] OPENAI_API_KEY not set — skipping OpenAI endpoint seed.")
+ return 0
+
+ # Imported lazily (after the key check) so a keyless deploy pays no import cost.
+ from core.database import ModelEndpoint, SessionLocal, init_db
+ from src.settings import load_settings, save_settings
+
+ # Tables + migrations are idempotent; the app re-runs init_db() at startup.
+ init_db()
+
+ model = (os.getenv("OPENAI_DEFAULT_MODEL") or DEFAULT_MODEL).strip() or DEFAULT_MODEL
+
+ db = SessionLocal()
+ try:
+ existing = (
+ db.query(ModelEndpoint)
+ .filter(ModelEndpoint.base_url.like("%api.openai.com%"))
+ .first()
+ )
+ if existing is not None:
+ log.info("[seed] OpenAI endpoint already present (%s) — nothing to do.", existing.id)
+ return 0
+
+ ep_id = str(uuid.uuid4())[:8]
+ ep = ModelEndpoint(
+ id=ep_id,
+ name="OpenAI",
+ base_url="https://api.openai.com/v1",
+ api_key=api_key, # EncryptedText encrypts at rest via the shared app key
+ is_enabled=True,
+ model_type="llm",
+ endpoint_kind="api",
+ # Pin (and cache) the default model so the picker + composer work even
+ # when the key can't list /v1/models. A key with Models-read permission
+ # still gets the full list via the app's background refresh.
+ pinned_models=json.dumps([model]),
+ cached_models=json.dumps([model]),
+ owner=None, # shared: visible to the admin and any additional users
+ )
+ db.add(ep)
+ db.commit()
+
+ settings = load_settings()
+ settings["default_endpoint_id"] = ep_id
+ settings["default_model"] = model
+ save_settings(settings)
+
+ log.info("[seed] Seeded OpenAI endpoint %s (default model %r).", ep_id, model)
+ finally:
+ db.close()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/render.yaml b/render.yaml
new file mode 100644
index 000000000..8f32b79b3
--- /dev/null
+++ b/render.yaml
@@ -0,0 +1,135 @@
+# Render Blueprint for Odysseus — deploy the self-hosted AI workspace in one click.
+# https://render.com/docs/blueprint-spec
+#
+# Three services, grouped under an "odysseus" project: the Odysseus web app, a
+# bundled SearXNG (web search) and a bundled ChromaDB (vector store). The web app
+# talks to the two private services over Render's internal network. Fill the
+# provider API keys in the deploy form (stored as Render secrets, never committed).
+
+previews:
+ generation: "off"
+
+projects:
+ - name: odysseus
+ environments:
+ - name: production
+ services:
+ # ---- Odysseus web app ------------------------------------------
+ - type: web
+ name: odysseus
+ runtime: docker
+ dockerfilePath: ./Dockerfile.render
+ plan: standard # 2 GB — in-process fastembed embeddings need headroom
+ region: oregon
+ healthCheckPath: /api/health
+ disk:
+ name: odysseus-data # SQLite DB, encrypted key store, uploads, embed cache
+ mountPath: /app/data
+ sizeGB: 10
+ envVars:
+ # Auth / security — locked down for a public host by default.
+ - key: AUTH_ENABLED
+ value: "true"
+ - key: LOCALHOST_BYPASS
+ value: "false"
+ - key: SECURE_COOKIES
+ value: "true" # Render serves HTTPS
+ # Trusted proxy hops in front of the app — used by every IP-keyed
+ # rate limiter (auth login/signup/setup AND the demo caps). Render
+ # runs one edge proxy that appends the real client IP to the RIGHT
+ # of X-Forwarded-For, so we read 1 hop from the right (the leftmost
+ # entry is client-spoofable). Bump only if you front this service
+ # with additional trusted proxies. On first traffic the app logs a
+ # one-time "[trusted-ip] X-Forwarded-For sample" line — check it to
+ # confirm this value resolves your true client IP.
+ - key: TRUSTED_PROXY_HOPS
+ value: "1"
+ - key: ODYSSEUS_ADMIN_USER
+ value: admin
+ - key: ODYSSEUS_ADMIN_PASSWORD
+ generateValue: true # strong first-login password; view it in the dashboard
+ # Storage.
+ - key: DATABASE_URL
+ value: sqlite:///./data/app.db
+ - key: FASTEMBED_CACHE_PATH
+ value: /app/data/fastembed # persist the local embedding model on the disk
+ # Bundled services (internal network).
+ - key: SEARXNG_INSTANCE
+ value: http://odysseus-searxng:8080
+ - key: CHROMADB_HOST
+ fromService:
+ type: pserv
+ name: odysseus-chromadb
+ property: host
+ - key: CHROMADB_PORT
+ value: "8000"
+ # ---- Provider credentials — set these in the one-click deploy form. ----
+ # All optional per feature: OpenAI powers chat/agents; a search key
+ # (Brave/Tavily/Serper/Google) enriches research beyond the bundled SearXNG;
+ # HF_TOKEN is only for gated Hugging Face models.
+ - key: OPENAI_API_KEY
+ sync: false
+ - key: OPENAI_DEFAULT_MODEL
+ value: gpt-5.6-sol # seeds the default chat model on first boot; change here or in the app
+ - key: DATA_BRAVE_API_KEY
+ sync: false
+ - key: TAVILY_API_KEY
+ sync: false
+ - key: SERPER_API_KEY
+ sync: false
+ - key: GOOGLE_API_KEY
+ sync: false
+ - key: GOOGLE_PSE_CX
+ sync: false
+ - key: HF_TOKEN
+ sync: false
+ # ---- Demo mode — opt-in public showcase (default OFF) -------
+ # DEMO=false keeps the full authenticated app (forks get this).
+ # DEMO=true opens a public, no-signup, locked-down chat demo that
+ # spends this deploy's OPENAI_API_KEY. See README "Demo mode".
+ # NOTE: the rate limit and per-IP daily ceiling below are keyed on
+ # the trusted client IP (Render's proxy-attested XFF), so cookie
+ # clearing / owner churn can't reset them. They bound burn RATE;
+ # they don't replace a hard dollar cap. Set a monthly usage limit on
+ # your OpenAI project for the true ceiling before enabling this.
+ - key: DEMO
+ value: "false" # opt-in; not a secret. "true"/"1"/"yes" turns it on.
+ - key: DEMO_MODEL
+ value: gpt-5.6-luna # cheap current OpenAI tier for the demo path (env OPENAI_API_KEY)
+ - key: DEMO_RATE_LIMIT_PER_MINUTE
+ value: "10" # chat requests/min per client IP; 0 disables this dimension
+ - key: DEMO_MAX_MESSAGES_PER_SESSION
+ value: "30" # total messages per visitor cookie session (UX friction); 0 disables
+ - key: DEMO_MAX_MESSAGES_PER_IP_PER_DAY
+ value: "200" # hard per-IP daily message ceiling (the real backstop); 0 disables
+ - key: DEMO_MAX_OUTPUT_TOKENS
+ value: "512" # cost cap on output tokens per demo LLM call
+
+ # ---- SearXNG (web search) — private ----------------------------
+ - type: pserv
+ name: odysseus-searxng
+ runtime: docker
+ dockerfilePath: ./searxng.Dockerfile
+ plan: starter
+ region: oregon
+ envVars:
+ - key: SEARXNG_SECRET
+ generateValue: true
+ - key: SEARXNG_BASE_URL
+ value: http://odysseus-searxng:8080/
+
+ # ---- ChromaDB (vector store) — private -------------------------
+ - type: pserv
+ name: odysseus-chromadb
+ runtime: image
+ image:
+ url: docker.io/chromadb/chroma:1.0.20
+ plan: starter
+ region: oregon
+ envVars:
+ - key: ANONYMIZED_TELEMETRY
+ value: "FALSE"
+ disk:
+ name: chromadb-data
+ mountPath: /chroma/chroma
+ sizeGB: 5
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 000000000..cdc3aa65b
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,11 @@
+# Development / test dependencies — NOT installed in the production image.
+# The hosted Render image (Dockerfile.render) installs only requirements.txt,
+# and tests/ is excluded from the build context (.dockerignore), so this test
+# tooling stays out of the shipped container. Install locally with:
+# pip install -r requirements.txt -r requirements-dev.txt
+pytest==9.1.1
+pytest-asyncio==1.4.0
+# starlette.testclient prefers httpx2 since Starlette 1.2.0 and warns on every
+# TestClient import when only classic httpx is present. Runtime code keeps
+# using `httpx` (in requirements.txt); this is test-client only.
+httpx2==2.7.0
diff --git a/requirements.txt b/requirements.txt
index 3c5114f53..458e64119 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,53 +1,52 @@
-fastapi
-uvicorn
-python-multipart
-python-dotenv
-httpx
-httpcore>=1.0,<2.0
-pydantic>=2.13.4
-pydantic-settings>=2.14.1
-SQLAlchemy
-pypdf
-beautifulsoup4
-charset-normalizer
-numpy
+# Core runtime dependencies, pinned for reproducible builds.
+# Tested against Python 3.14 (see Dockerfile / Dockerfile.render). Bump
+# deliberately after verifying the app still boots and tests pass.
+fastapi==0.139.2
+uvicorn==0.51.0
+python-multipart==0.0.32
+python-dotenv==1.2.2
+httpx==0.28.1
+httpcore==1.0.9
+pydantic==2.13.4
+pydantic-settings==2.14.2
+SQLAlchemy==2.0.51
+pypdf==6.14.2
+beautifulsoup4==4.15.0
+charset-normalizer==3.4.9
+numpy==2.5.1
# Vector store + local embeddings for RAG, semantic memory, and tool
# selection. Used on core agent paths, so installed by default — the app
# still degrades to keyword fallback if they're ever missing.
# chromadb-client is the lightweight HTTP client (talks to a standalone
# ChromaDB service); fastembed runs local ONNX embeddings.
-chromadb-client
-fastembed
-youtube-transcript-api
+chromadb-client==1.5.9
+fastembed==0.8.0
+youtube-transcript-api==1.2.4
# Markdown rendering for research reports (src/visual_report.py).
# Imported at module-top so it's a hard core dep, not optional.
-markdown
+markdown==3.10.2
# HTML sanitizer for rendered research reports (src/visual_report.py). Report
# content is untrusted (LLM output over crawled pages) and report pages run
# under a relaxed CSP, so the rendered HTML is allowlist-sanitized.
-nh3
+nh3==0.3.6
# Calendar .ics import/export (routes/calendar_routes.py).
-icalendar
+icalendar==7.2.0
# Recurrence rule expansion for calendar events (routes/calendar_routes.py).
# Imported directly as dateutil.rrule — make it explicit even though caldav
# pulls it in transitively.
-python-dateutil
+python-dateutil==2.9.0.post0
# CalDAV sync (src/caldav_sync.py). Handles PROPFIND discovery + REPORT
# fetch across Radicale, Nextcloud, Apple, Fastmail; we'd be reinventing
# the protocol without it.
-caldav
-cryptography
-bcrypt
+caldav==3.2.1
+cryptography==49.0.0
+bcrypt==5.0.0
# Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a
# breaking rewrite, so keep fresh installs on the maintained v1 line until the
# servers are migrated together.
-mcp<2
-pyotp
-qrcode[pil]
-croniter
-pytest
-pytest-asyncio
-# starlette.testclient prefers httpx2 since Starlette 1.2.0 and warns on every
-# TestClient import when only classic httpx is present. Runtime code keeps
-# using `httpx` above; this is test-client only.
-httpx2
+mcp==1.28.1
+pyotp==2.10.0
+qrcode[pil]==8.2
+croniter==6.2.4
+# Test/dev tooling (pytest, pytest-asyncio, httpx2) lives in requirements-dev.txt
+# so it stays out of the production image — see that file.
diff --git a/routes/assistant_routes.py b/routes/assistant_routes.py
index 0b609e37f..8c5f93343 100644
--- a/routes/assistant_routes.py
+++ b/routes/assistant_routes.py
@@ -16,6 +16,7 @@
from core.database import SessionLocal, CrewMember, ScheduledTask
from src.auth_helpers import get_current_user
+from src import demo as _demo
from core.auth import RESERVED_USERNAMES
from src.task_scheduler import compute_next_run
@@ -94,7 +95,7 @@ def _owner(request: Request) -> str:
async def _get_or_create(owner: str) -> CrewMember:
"""Return the per-owner assistant CrewMember, creating it on demand."""
- if not owner or owner in RESERVED_USERNAMES:
+ if not owner or owner in RESERVED_USERNAMES or _demo.is_demo_owner(owner):
raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}")
db = SessionLocal()
try:
diff --git a/routes/auth_routes.py b/routes/auth_routes.py
index 5c7a4e04a..33e460fb9 100644
--- a/routes/auth_routes.py
+++ b/routes/auth_routes.py
@@ -14,7 +14,8 @@
from core.atomic_io import atomic_write_json, atomic_write_text
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
-from src.rate_limiter import RateLimiter
+from src import demo as _demo
+from src.rate_limiter import RateLimiter, trusted_client_ip
from src.settings_scrub import scrub_settings
from src.settings import (
load_settings as _load_settings,
@@ -98,7 +99,7 @@ def _get_current_user(request: Request) -> Optional[str]:
@router.post("/setup")
async def first_run_setup(body: SetupRequest, request: Request):
"""Create initial admin account. Only works if no accounts exist."""
- if not _setup_limiter.check(request.client.host):
+ if not _setup_limiter.check(trusted_client_ip(request)):
raise HTTPException(429, "Too many requests — try again later")
if auth_manager.is_configured:
raise HTTPException(400, "Already configured")
@@ -116,7 +117,7 @@ async def first_run_setup(body: SetupRequest, request: Request):
@router.post("/signup")
async def signup(body: SignupRequest, request: Request):
"""Create a new user account. Only works if signup is enabled by admin."""
- if not _signup_limiter.check(request.client.host):
+ if not _signup_limiter.check(trusted_client_ip(request)):
raise HTTPException(429, "Too many requests — try again later")
if not auth_manager.is_configured:
raise HTTPException(400, "Run setup first")
@@ -135,7 +136,7 @@ async def signup(body: SignupRequest, request: Request):
@router.post("/login")
async def login(body: LoginRequest, request: Request, response: Response):
- if not _login_limiter.check(request.client.host):
+ if not _login_limiter.check(trusted_client_ip(request)):
raise HTTPException(429, "Too many requests — try again later")
# Verify password first
username = body.username.strip().lower()
@@ -174,10 +175,33 @@ async def logout(request: Request, response: Response):
return {"ok": True}
@router.get("/status")
- async def auth_status(request: Request):
+ async def auth_status(request: Request, response: Response):
token = request.cookies.get(SESSION_COOKIE)
result = auth_manager.status(token)
result["signup_enabled"] = auth_manager.signup_enabled
+ # Demo visitors reach this route (it's auth-exempt) with no session
+ # token, so auth_manager.status() reports them unauthenticated. When
+ # DEMO mode is on and the visitor carries a well-formed demo cookie,
+ # surface a synthetic authenticated status + the locked-down privilege
+ # profile so the SPA renders the chat UI (and hides disabled controls)
+ # instead of gating on login. This route is auth-exempt and runs BEFORE
+ # the middleware demo path, so resolve the demo owner from the cookie
+ # directly here. When the visitor has no demo cookie yet, resolve_demo_owner
+ # mints one; set it on the response so an owner is stable even if the SPA
+ # calls /status before GET / (the middleware demo path also sets it). Not
+ # doing so would mint a fresh owner on every such call.
+ if _demo.DEMO_MODE and not result.get("authenticated"):
+ owner, new_cookie = _demo.resolve_demo_owner(request)
+ if _demo.is_demo_owner(owner):
+ if new_cookie:
+ _demo.set_demo_cookie(response, new_cookie)
+ result["configured"] = True
+ result["authenticated"] = True
+ result["username"] = owner
+ result["is_admin"] = False
+ result["demo"] = True
+ result["privileges"] = auth_manager.get_privileges(owner)
+ return result
# Include the caller's effective privileges so the frontend can
# hide / dim UI controls the user isn't allowed to use. Admins get
# ADMIN_PRIVILEGES (everything on), regular users get their stored
diff --git a/routes/chat_routes.py b/routes/chat_routes.py
index b081d5f1c..5e1fa1311 100644
--- a/routes/chat_routes.py
+++ b/routes/chat_routes.py
@@ -43,6 +43,7 @@
)
from src.action_intents import ToolIntent, classify_tool_intent as _classify_tool_intent
from src.image_model_ids import looks_like_image_generation_model
+from src import demo as _demo
from src.tool_policy import (
WEB_TOOL_NAMES,
build_effective_tool_policy,
@@ -881,15 +882,30 @@ async def chat_stream(request: Request) -> StreamingResponse:
sess = session_manager.get_session(session)
owner = effective_user(request)
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
- if _clear_orphaned_session_endpoint(sess, owner=owner):
- raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
- # Issue #587: picker shows a model from the endpoint cache but
- # s.model never made it onto the DB row (first-send race after
- # endpoint setup, or a previous endpoint delete/recreate). Pull
- # the first cached model off the matching endpoint so the
- # upstream isn't called with model="" (which surfaces as a
- # generic 401/503).
- _recover_empty_session_model(sess, session, owner=owner)
+ # Demo caps: check rate limit + per-session message cap BEFORE any
+ # token spend. A tripped cap renders as a normal assistant turn
+ # (friendly SSE), never a 500 or hang.
+ if _demo.is_demo_owner(owner):
+ _demo_msg = _demo.check_demo_limits(owner, _demo.demo_client_ip(request))
+ if _demo_msg:
+ return StreamingResponse(
+ _demo.demo_limit_sse(_demo_msg), media_type="text/event-stream"
+ )
+ # Demo owners have no per-owner ModelEndpoint rows — the pinned
+ # model/endpoint/env-key are authoritative here. Apply them up
+ # front and SKIP the endpoint-row orphan/recovery checks, which
+ # would otherwise clear the (row-less) endpoint and 400.
+ _demo.apply_demo_session_config(sess)
+ else:
+ if _clear_orphaned_session_endpoint(sess, owner=owner):
+ raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
+ # Issue #587: picker shows a model from the endpoint cache but
+ # s.model never made it onto the DB row (first-send race after
+ # endpoint setup, or a previous endpoint delete/recreate). Pull
+ # the first cached model off the matching endpoint so the
+ # upstream isn't called with model="" (which surfaces as a
+ # generic 401/503).
+ _recover_empty_session_model(sess, session, owner=owner)
if not getattr(sess, "model", "").strip():
raise HTTPException(
400,
@@ -971,6 +987,23 @@ async def chat_stream(request: Request) -> StreamingResponse:
)
allow_tool_preprocessing = not pre_context_tool_policy.block_all_tool_calls
+ # Demo lockdown — force plain chat before build_chat_context runs, since
+ # it (and auto-escalation above) can attach docs / run tools BEFORE the
+ # per-tool privilege gate fires. Belt-and-suspenders with DEMO_PRIVILEGES:
+ # this kills the write/execute/escalation surfaces for the turn.
+ # Web search is intentionally NOT disabled here — demo visitors may use
+ # it; the turn's `use_web` / `allow_web_search` flags are honored as-is.
+ if _demo.is_demo_owner(owner):
+ chat_mode = "chat"
+ auto_escalated = False
+ _tool_intent = None
+ use_research = "false"
+ use_rag = "false"
+ do_research = False
+ att_ids = []
+ active_doc_id = ""
+ preset_id = None
+
# Build shared context (stream path uses enhanced_message for context preface)
ctx = await build_chat_context(
sess, request, chat_handler, chat_processor,
@@ -994,6 +1027,11 @@ async def chat_stream(request: Request) -> StreamingResponse:
allow_tool_preprocessing=allow_tool_preprocessing,
)
+ # Demo output-token cap: clamp to the tighter of the request value and
+ # DEMO_MAX_OUTPUT_TOKENS (0/None would mean "no cap" downstream).
+ if _demo.is_demo_owner(ctx.user):
+ ctx.preset.max_tokens = _demo.clamp_demo_output_tokens(ctx.preset.max_tokens)
+
_research_flags = {"do": do_research} # Mutable container for generator scope
# Query active document — prefer explicit ID from frontend, fall back to session lookup
diff --git a/routes/model_routes.py b/routes/model_routes.py
index 600150a66..3630c50b3 100644
--- a/routes/model_routes.py
+++ b/routes/model_routes.py
@@ -30,6 +30,7 @@
build_headers,
)
from src.auth_helpers import _auth_disabled, effective_user, owner_filter
+from src import demo as _demo
logger = logging.getLogger(__name__)
@@ -320,8 +321,11 @@ def _rewrite_loopback_for_docker(base_url: str, *, container_local: bool = False
# A model ID matches if it starts with or equals a curated entry.
_PROVIDER_CURATED = {
"openai": [
- "gpt-5.2", "gpt-5.2-pro", "gpt-5", "gpt-5-pro", "gpt-5-mini", "gpt-5-nano",
- "gpt-4o", "gpt-4o-mini", "o3", "o4-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano",
+ "gpt-5.6-sol", "gpt-5.6-luna",
+ "gpt-5.2", "gpt-5.2-pro", "gpt-5.2-codex",
+ "gpt-5", "gpt-5-pro", "gpt-5-mini", "gpt-5-nano",
+ "gpt-4o", "gpt-4o-mini", "o3", "o4-mini",
+ "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano",
"gpt-image-1.5", "gpt-image-1", "dall-e-3", "tts-1", "whisper-1",
],
"anthropic": [
@@ -2419,6 +2423,16 @@ def get_default_chat(request: Request):
_user = _gcu(request) or ""
except Exception:
_user = ""
+ # Demo visitors share the deployer's pinned demo config, not any
+ # per-user pref or DB-resolved endpoint. A demo owner (demo-) owns
+ # no endpoints and has no prefs, so the owner-scoped resolution below
+ # returns empty — leaving the composer stuck on "No chat session
+ # active" even though chat works. sync_session_metadata's
+ # apply_demo_session_config overrides every demo session to
+ # OPENAI_CHAT_URL + DEMO_MODEL + the env key on read anyway, so hand the
+ # composer that same pinned pair so it can create the session at all.
+ if _demo.is_demo_request(request, _user):
+ return {"endpoint_id": "", "endpoint_url": _demo.OPENAI_CHAT_URL, "model": _demo.DEMO_MODEL}
# Admins resolve via the global defaults (they own them, and the
# scoped resolution was making the picker disappear for them).
# Regular users get per-user prefs with NO global fallback for the
diff --git a/routes/session_routes.py b/routes/session_routes.py
index dc29a64e4..59188b929 100644
--- a/routes/session_routes.py
+++ b/routes/session_routes.py
@@ -12,6 +12,7 @@
from src.request_models import SessionResponse
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
from src.auth_helpers import effective_user, _auth_disabled, owner_filter
+from src import demo as _demo
from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs
from src.session_actions import is_session_recently_active
from src.upload_handler import reserve_message_upload_references
@@ -342,7 +343,20 @@ def create_session(
user = effective_user(request)
endpoint_api_key = ""
endpoint_base_url = ""
- _reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
+ # Demo visitors own no ModelEndpoint rows, and apply_demo_session_config
+ # force-pins every demo session to the env key + DEMO_MODEL on each read
+ # (see chat_routes). Discard whatever endpoint the composer posted, pin
+ # the trusted demo pair here, and skip validation + the raw-URL guard:
+ # the client URL is inert (never dialed) and the guard would otherwise
+ # 403 a non-admin demo owner out of ever creating the session — the bug
+ # behind the "No chat session active" composer message.
+ if _demo.is_demo_request(request, user):
+ endpoint_id = ""
+ endpoint_url = _demo.OPENAI_CHAT_URL
+ model = _demo.DEMO_MODEL
+ skip_val = True
+ else:
+ _reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
if endpoint_id and endpoint_id.strip():
from core.database import ModelEndpoint
from src.auth_helpers import owner_filter
@@ -912,7 +926,7 @@ def sessions_save_now(request: Request):
def create_session_openai(
request: Request,
name: str = Form("New Chat (OpenAI)"),
- model: str = Form("gpt-4o"),
+ model: str = Form("gpt-5.6-sol"),
rag: str = Form(None)
):
if not OPENAI_API_KEY:
diff --git a/searxng.Dockerfile b/searxng.Dockerfile
new file mode 100644
index 000000000..97c40c7c0
--- /dev/null
+++ b/searxng.Dockerfile
@@ -0,0 +1,17 @@
+# SearXNG image for hosting Odysseus on Render.
+#
+# The stock SearXNG image does not enable the `json` output format that Odysseus
+# depends on, and on Render we can't bind-mount the repo's config file the way
+# docker-compose.yml does. So bake Odysseus's settings.yml into the image and
+# render it (with a per-deploy secret) at boot via the entrypoint below.
+#
+# Pinned deliberately (not :latest): Odysseus waits on SearXNG's health, so a
+# broken upstream tag would block the whole app. 2026.6.2 crashes on boot
+# (KeyError: 'default_doi_resolver'). Bump only after verifying a newer tag boots.
+FROM docker.io/searxng/searxng:2026.5.31-7159b8aed
+
+COPY config/searxng/settings.yml /usr/local/share/searxng-settings.yml.template
+COPY docker/searxng-render-entrypoint.sh /usr/local/bin/searxng-render-entrypoint.sh
+RUN chmod +x /usr/local/bin/searxng-render-entrypoint.sh
+
+ENTRYPOINT ["/usr/local/bin/searxng-render-entrypoint.sh"]
diff --git a/src/app_helpers.py b/src/app_helpers.py
index 1b915a5a2..c043628dc 100644
--- a/src/app_helpers.py
+++ b/src/app_helpers.py
@@ -2,6 +2,7 @@
import base64
import logging
import os
+import re
from fastapi import HTTPException
from fastapi.responses import HTMLResponse
@@ -9,6 +10,9 @@
logger = logging.getLogger(__name__)
+# Opening , with or without attributes (``, ``).
+_HEAD_OPEN_RE = re.compile(r"]*>", re.IGNORECASE)
+
def read_if_exists(path: str) -> str:
"""Read file if it exists, return empty string otherwise."""
try:
@@ -37,6 +41,13 @@ def serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
template surfaces in 5xx alerting instead of hiding behind a 404. If a
future caller serves a client-influenced path where 404 is correct, branch
that at the call site rather than defaulting this shared helper to 404.
+
+ When the request is a demo visitor (``request.state.is_demo``), a tiny
+ synchronous ``window.__ODYSSEUS_DEMO=true`` flag is injected right after
+ ```` so the SPA's very-early fetch wrapper knows not to bounce demo
+ visitors to /login on the 401s from locked-down endpoints. It's set BEFORE
+ any module script runs, so there's no race. A template with no ````
+ raises 500 rather than injecting the flag too late to work.
"""
try:
with open(file_path, "r", encoding="utf-8") as f:
@@ -46,6 +57,17 @@ def serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
raise HTTPException(500, "Internal server error")
nonce = getattr(request.state, "csp_nonce", "")
html = html.replace("{{CSP_NONCE}}", nonce)
+ if getattr(request.state, "is_demo", False):
+ flag = f''
+ # Insert immediately after the opening so it runs first. A
+ # template without a would put the flag after the fetch wrapper
+ # it's meant to gate, so fail loudly rather than shipping a demo page
+ # that silently bounces visitors to /login.
+ match = _HEAD_OPEN_RE.search(html)
+ if not match:
+ logger.error("No in %s — cannot inject the demo flag", file_path)
+ raise HTTPException(500, "Internal server error")
+ html = html[: match.end()] + flag + html[match.end() :]
return HTMLResponse(html)
diff --git a/src/chat_helpers.py b/src/chat_helpers.py
index a8f5f54a8..84defbfdd 100644
--- a/src/chat_helpers.py
+++ b/src/chat_helpers.py
@@ -42,7 +42,8 @@ def extract_urls(text: str) -> List[str]:
# models (Ollama/llama.cpp) that ship under many names. See issue #124.
_VISION_MODEL_KEYWORDS = (
# hosted
- "gpt-4o", "gpt-4.1", "gpt-4.5", "gpt-4-turbo", "gpt-4-vision",
+ "gpt-5.6-sol", "gpt-5.6-luna", "gpt-4o", "gpt-4.1", "gpt-4.5",
+ "gpt-4-turbo", "gpt-4-vision",
"claude-sonnet", "claude-opus", "claude-haiku", "gemini",
# open / local
"vision", "multimodal", "llava", "bakllava", "moondream", "pixtral", "minicpm",
diff --git a/src/demo.py b/src/demo.py
new file mode 100644
index 000000000..185dc868f
--- /dev/null
+++ b/src/demo.py
@@ -0,0 +1,326 @@
+"""Demo mode — an opt-in, public, locked-down chat showcase.
+
+Off by default (``DEMO=false``) so a fresh fork gets the full authenticated
+app. When ``DEMO=true``, ``AuthMiddleware`` mints a per-visitor synthetic owner
+and lets an unauthenticated visitor reach ONLY the core chat surface, under a
+least-privilege profile, rate-limited, with ephemeral (in-memory) history that
+is never written to the deployer's disk.
+
+Everything demo-specific lives here so the rest of the app calls into this
+module rather than scattering ``if DEMO`` branches. When the flag is off, this
+module is inert: ``DEMO_MODE`` is ``False`` and none of the hooks fire.
+
+Security notes:
+ * The pinned model + endpoint + API key are applied at read time
+ (``sync_session_metadata``) and never persisted — the key stays env-only.
+ * Demo owners are ``demo-`` strings; ``is_demo_owner`` is a prefix
+ check. The literal ``"demo"`` remains a RESERVED_USERNAME (a different
+ string), so there is no collision with the account sentinel.
+ * The route whitelist (``is_demo_allowed``) is the middleware boundary; the
+ privilege profile (``DEMO_PRIVILEGES``) is the in-handler boundary. Both
+ must hold for a capability to be reachable.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import threading
+import time
+import uuid
+from typing import Any, Dict, List, Optional, Tuple
+
+from src.rate_limiter import RateLimiter, trusted_client_ip
+
+
+def _flag(name: str, default: str = "false") -> bool:
+ """Parse a boolean env flag. true/1/yes (any case) is on; all else off."""
+ return os.getenv(name, default).strip().lower() in ("true", "1", "yes")
+
+
+def _int_env(name: str, default: int) -> int:
+ """Parse a non-negative int env var. Unset/invalid falls back to `default`
+ (a missing var must NEVER mean "unlimited"); only an explicit 0 disables a
+ dimension. Negative values are treated as invalid → default."""
+ raw = os.getenv(name)
+ if raw is None or not raw.strip():
+ return default
+ try:
+ val = int(raw.strip())
+ except ValueError:
+ return default
+ return val if val >= 0 else default
+
+
+# --- The flag ---------------------------------------------------------------
+DEMO_MODE: bool = _flag("DEMO", "false")
+
+# --- Per-visitor identity ---------------------------------------------------
+DEMO_COOKIE = "odysseus_demo" # separate from the authed odysseus_session cookie
+DEMO_OWNER_PREFIX = "demo-" # owner ids look like demo-<32 hex uuid>
+_TOKEN_RE = re.compile(r"^[0-9a-f]{32}$")
+
+# --- Pinned model + endpoint (env key, never persisted) ---------------------
+DEMO_MODEL = (os.getenv("DEMO_MODEL", "").strip() or "gpt-5.6-luna")
+OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions"
+
+# --- Usage limits (only consulted when DEMO_MODE). 0 disables the dimension. -
+DEMO_RATE_LIMIT_PER_MINUTE = _int_env("DEMO_RATE_LIMIT_PER_MINUTE", 10)
+DEMO_MAX_MESSAGES_PER_SESSION = _int_env("DEMO_MAX_MESSAGES_PER_SESSION", 30)
+# IP-scoped total ceiling — the real volume backstop. The per-session cap is
+# cookie-based (ephemeral history) so it's UX friction; this one is keyed on the
+# trusted client IP (see demo_client_ip) and survives cookie/owner churn.
+DEMO_MAX_MESSAGES_PER_IP_PER_DAY = _int_env("DEMO_MAX_MESSAGES_PER_IP_PER_DAY", 200)
+DEMO_MAX_OUTPUT_TOKENS = _int_env("DEMO_MAX_OUTPUT_TOKENS", 512)
+if DEMO_MAX_OUTPUT_TOKENS <= 0:
+ # A 0/unset output cap would mean "no cap" downstream — keep a sane floor so
+ # the demo can never be turned into an unbounded free generator.
+ DEMO_MAX_OUTPUT_TOKENS = 512
+
+LIMIT_MESSAGE = (
+ "**Demo limit reached — deploy your own to keep going.**\n\n"
+ "This is a public demo with usage caps so it stays affordable. Click "
+ "**Deploy to Render** in the README to run your own private instance."
+)
+
+# --- Least-privilege profile ------------------------------------------------
+# Consumed by AuthManager.get_privileges for demo owners; this drives the
+# existing per-user enforcement in routes/chat_routes.py (which disables the
+# matching tools) and _enforce_chat_privileges (allowed_models). Everything
+# that writes, executes, spends extra, or reaches outward is OFF.
+DEMO_PRIVILEGES: Dict[str, Any] = {
+ "can_use_agent": False, # forces plain chat mode (no tool loop)
+ "can_use_browser": False, # no builtin browser
+ "can_use_bash": False, # no shell / python / file tools
+ "can_use_documents": False, # no document create/edit
+ "can_use_research": False, # no deep research
+ "can_generate_images": False, # no metered image spend
+ "can_manage_memory": False, # no memory/skills writes
+ # Per-session cap is enforced in-memory (demo history isn't persisted, so a
+ # DB-count daily cap would always read 0). Keep this at 0 here.
+ "max_messages_per_day": 0,
+ "allowed_models": [DEMO_MODEL],
+ "allowed_models_restricted": True,
+ "block_all_models": False,
+}
+
+# --- Route whitelist (the middleware boundary) ------------------------------
+# The ONLY surface a demo visitor may reach. Auth-exempt routes (login, status,
+# features, settings, version, /static) are handled by AuthMiddleware BEFORE the
+# demo path runs, so they need not be repeated here.
+_DEMO_ALLOWED_EXACT = {
+ ("GET", "/"), # SPA shell
+ ("GET", "/api/default-chat"), # supplies endpoint+model so first send can create a session
+ ("POST", "/api/session"), # create the chat session (endpoint/model forced server-side)
+ ("POST", "/api/chat_stream"), # send a message + streamed reply (capabilities locked below)
+}
+_DEMO_ALLOWED_PREFIXES: Tuple[Tuple[str, str], ...] = (("GET", "/static"),)
+
+
+def is_demo_owner(username: Optional[str]) -> bool:
+ """True for a per-visitor demo owner id (demo-) WHEN demo mode is on.
+
+ Gated on DEMO_MODE so a normal fork stays inert: a user who registers a
+ ``demo-`` username on a non-demo deploy is an ordinary user, NOT silently
+ locked into the demo least-privilege profile with their chat history dropped.
+ This is the single choke point every caller (get_privileges, session_manager,
+ task_scheduler, chat/auth routes) shares, so the gate can't drift between
+ them. Prefix check — does NOT match the literal reserved username "demo".
+
+ This module is a leaf (stdlib + ``src.rate_limiter`` only), so every caller
+ imports it at module top rather than lazily — there is no cycle to dodge and
+ no import-failure path to fall back to: a broken ``src.demo`` fails the boot
+ loudly instead of silently degrading a live deploy's demo gate."""
+ return DEMO_MODE and bool(username) and str(username).startswith(DEMO_OWNER_PREFIX)
+
+
+def is_demo_request(request, owner: Optional[str]) -> bool:
+ """True when this request should be served as a demo request: DEMO_MODE is on
+ AND either the middleware flagged it (``request.state.is_demo``) or ``owner``
+ is a demo owner. The single predicate the routes share so the demo gate can't
+ drift between call sites."""
+ return DEMO_MODE and (
+ getattr(request.state, "is_demo", False) or is_demo_owner(owner)
+ )
+
+
+def is_demo_allowed(method: str, path: str) -> bool:
+ """True if (method, path) is on the demo route whitelist."""
+ if (method, path) in _DEMO_ALLOWED_EXACT:
+ return True
+ return any(method == m and path.startswith(p) for m, p in _DEMO_ALLOWED_PREFIXES)
+
+
+# --- Per-visitor cookie / owner ---------------------------------------------
+def resolve_demo_owner(request) -> Tuple[str, Optional[str]]:
+ """Return ``(owner, new_cookie_value)`` for a demo visitor.
+
+ Reuses the visitor's existing demo cookie when present and well-formed;
+ otherwise mints a fresh unguessable id. ``new_cookie_value`` is the raw
+ token to set on the response (or ``None`` when the cookie already existed).
+ """
+ tok = request.cookies.get(DEMO_COOKIE, "")
+ if tok and _TOKEN_RE.match(tok):
+ return DEMO_OWNER_PREFIX + tok, None
+ new = uuid.uuid4().hex
+ return DEMO_OWNER_PREFIX + new, new
+
+
+def set_demo_cookie(response, token: str) -> None:
+ """Set the per-visitor demo cookie: httponly, samesite=lax, secure per
+ SECURE_COOKIES (true on Render), short-lived (history is ephemeral)."""
+ response.set_cookie(
+ key=DEMO_COOKIE,
+ value=token,
+ httponly=True,
+ samesite="lax",
+ secure=os.getenv("SECURE_COOKIES", "false").lower() == "true",
+ max_age=60 * 60 * 24, # 1 day; a returning visitor keeps their session cap within it
+ path="/",
+ )
+
+
+# --- Session config (pinned model + env key, never persisted) ---------------
+def apply_demo_session_config(session) -> None:
+ """Force a demo session to talk to OpenAI with the pinned model and the
+ server's env OPENAI_API_KEY. Called from sync_session_metadata so this is
+ authoritative on every read — the key is never read from, or written to, the
+ DB. No-op-safe when the env key is missing (the LLM call then fails cleanly
+ as "server missing key" rather than leaking a partial config)."""
+ key = os.getenv("OPENAI_API_KEY")
+ session.endpoint_url = OPENAI_CHAT_URL
+ session.model = DEMO_MODEL
+ session.headers = {"Authorization": f"Bearer {key}"} if key else {}
+
+
+# --- Rate + per-session message limits --------------------------------------
+# Gate on DEMO_MODE too: a normal fork imports this module (via app.py) but must
+# stay inert, so don't build a limiter it will never consult.
+_rate_limiter: Optional[RateLimiter] = (
+ RateLimiter(max_requests=DEMO_RATE_LIMIT_PER_MINUTE, window_seconds=60)
+ if DEMO_MODE and DEMO_RATE_LIMIT_PER_MINUTE > 0
+ else None
+)
+
+_PURGE_AFTER = 60 * 60 * 24 # forget a counter a day after its last activity
+
+# owner -> [message_count, last_touch_monotonic]
+_session_counts: Dict[str, List[float]] = {}
+_counts_lock = threading.Lock()
+_last_purge = time.monotonic()
+
+# trusted_client_ip -> [message_count, window_start_monotonic]. Keyed on the IP
+# (not the cookie/owner) so it survives cookie clearing and owner churn — this is
+# the real backstop. The count resets once a full day elapses from window start.
+_ip_counts: Dict[str, List[float]] = {}
+_ip_lock = threading.Lock()
+_ip_last_purge = time.monotonic()
+
+
+def _purge_stale(store: Dict[str, List[float]], last_purge: float, now: float) -> float:
+ """Drop counters idle longer than _PURGE_AFTER so ``store`` can't grow without
+ bound. Returns the new last-purge timestamp (unchanged until it's time to
+ purge again). Call under the store's lock."""
+ if now - last_purge < _PURGE_AFTER:
+ return last_purge
+ stale = [k for k, v in store.items() if now - v[1] > _PURGE_AFTER]
+ for k in stale:
+ del store[k]
+ return now
+
+
+def check_demo_limits(owner: str, client_ip: str) -> Optional[str]:
+ """Return a friendly limit message if the visitor is over a cap, else None.
+
+ Call once per chat send, BEFORE spending the key. Enforces, in order:
+ (a) a sliding per-minute rate limit keyed on the trusted client IP,
+ (b) a per-session (cookie-scoped) message cap — UX friction, not a guard,
+ (c) an IP-scoped daily message ceiling (the real volume backstop).
+ A tripped cap returns text, never an exception, so the caller can render it
+ as a normal assistant turn instead of a 500/hang.
+
+ The per-session cap is checked (and consumed) BEFORE the IP counter is
+ touched, so a visitor already over their session cap returns without
+ spending a unit of the IP-scoped daily budget — the real cost backstop.
+ (The reverse over-count — an IP-capped visitor advancing the session
+ counter — is harmless: that counter is cookie-scoped UX friction, and the
+ visitor is blocked by the IP ceiling regardless.)
+
+ The rate limit and daily ceiling key on ``client_ip`` alone — the only
+ visitor-stable signal for an unauthenticated demo request. ``owner`` is
+ minted fresh for any client that ignores the demo cookie, so keying either
+ on it would let a cookieless client reset the window on every request.
+ Sharing a bucket across visitors behind one NAT errs toward more limiting —
+ correct for a cost guard.
+ """
+ global _last_purge, _ip_last_purge
+ if _rate_limiter is not None:
+ if not _rate_limiter.check(client_ip):
+ return LIMIT_MESSAGE
+ now = time.monotonic()
+ if DEMO_MAX_MESSAGES_PER_SESSION > 0:
+ with _counts_lock:
+ _last_purge = _purge_stale(_session_counts, _last_purge, now)
+ entry = _session_counts.get(owner)
+ used = entry[0] if entry else 0
+ if used >= DEMO_MAX_MESSAGES_PER_SESSION:
+ return LIMIT_MESSAGE
+ _session_counts[owner] = [used + 1, now]
+ if DEMO_MAX_MESSAGES_PER_IP_PER_DAY > 0 and client_ip:
+ with _ip_lock:
+ _ip_last_purge = _purge_stale(_ip_counts, _ip_last_purge, now)
+ entry = _ip_counts.get(client_ip)
+ if entry and now - entry[1] < _PURGE_AFTER:
+ used, start = int(entry[0]), entry[1]
+ else:
+ used, start = 0, now # first hit, or the day-long window expired
+ if used >= DEMO_MAX_MESSAGES_PER_IP_PER_DAY:
+ return LIMIT_MESSAGE
+ _ip_counts[client_ip] = [used + 1, start]
+ return None
+
+
+def demo_client_ip(request) -> str:
+ """Trusted client IP for the demo rate/volume caps.
+
+ Delegates to the shared ``trusted_client_ip`` so the demo caps and the
+ auth-route limiters agree on which ``X-Forwarded-For`` entry to trust
+ (governed by ``TRUSTED_PROXY_HOPS``) — see src/rate_limiter.py.
+ """
+ return trusted_client_ip(request)
+
+
+async def demo_limit_sse(message: str):
+ """SSE generator that renders `message` as a single assistant turn and ends.
+ Matches the chat_stream framing the frontend consumes (data: {delta} …
+ data: [DONE]) so a tripped limit shows as a normal reply, not a broken
+ stream."""
+ yield f'data: {json.dumps({"delta": message})}\n\n'
+ yield "data: [DONE]\n\n"
+
+
+def clamp_demo_output_tokens(current: Optional[int]) -> int:
+ """Return the max_tokens to use for a demo turn: the tighter of the
+ request's value and DEMO_MAX_OUTPUT_TOKENS. Treats 0/None (which mean
+ "no cap" downstream) as needing the demo cap applied."""
+ if not current or current > DEMO_MAX_OUTPUT_TOKENS:
+ return DEMO_MAX_OUTPUT_TOKENS
+ return current
+
+
+def log_startup_mode(logger) -> None:
+ """Log which mode booted so a misconfigured deploy is obvious in the logs."""
+ if DEMO_MODE:
+ logger.warning(
+ "[startup] DEMO mode ENABLED — public, no-signup, locked-down chat demo is live "
+ "and spends OPENAI_API_KEY. model=%s rate=%s/min msgs/session=%s "
+ "msgs/ip/day=%s max_output_tokens=%s",
+ DEMO_MODEL,
+ DEMO_RATE_LIMIT_PER_MINUTE or "unlimited",
+ DEMO_MAX_MESSAGES_PER_SESSION or "unlimited",
+ DEMO_MAX_MESSAGES_PER_IP_PER_DAY or "unlimited",
+ DEMO_MAX_OUTPUT_TOKENS,
+ )
+ else:
+ logger.info("[startup] normal (authenticated) mode — DEMO is off")
diff --git a/src/document_processor.py b/src/document_processor.py
index 8025e22e0..dc2d871ab 100644
--- a/src/document_processor.py
+++ b/src/document_processor.py
@@ -316,7 +316,8 @@ def _resolve_vl_model(configured: str, owner: str | None = None) -> tuple:
# Auto-detect: try known vision-capable models in priority order
candidates = [
- "gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini",
+ "gpt-5.6-sol", "gpt-5.6-luna", "gpt-4o", "gpt-4o-mini",
+ "gpt-4.1", "gpt-4.1-mini",
"claude-sonnet-4-5-20250929", "claude-opus-4-20250514",
"gemini-2.0-flash", "gemini-2.5-pro",
"llava", "pixtral", "qwen2-vl",
diff --git a/src/model_context.py b/src/model_context.py
index b6afa801e..684309d4b 100644
--- a/src/model_context.py
+++ b/src/model_context.py
@@ -128,6 +128,8 @@ def is_local_endpoint(url: str) -> bool:
'gpt-4.1': 1047576,
'gpt-4.1-mini': 1047576,
'gpt-4.1-nano': 1047576,
+ 'gpt-5.6-sol': 1047576,
+ 'gpt-5.6-luna': 1047576,
'gpt-4o': 128000,
'gpt-4o-mini': 128000,
'gpt-4-turbo': 128000,
diff --git a/src/model_discovery.py b/src/model_discovery.py
index 4d67502c5..c2581ede0 100644
--- a/src/model_discovery.py
+++ b/src/model_discovery.py
@@ -268,12 +268,14 @@ def get_providers(self) -> Dict[str, Any]:
if self.openai_api_key:
openai_models = [
+ "gpt-5.6-sol",
+ "gpt-5.6-luna",
+ "gpt-5.2",
+ "gpt-5.2-pro",
"gpt-5.2-codex",
+ "gpt-4o",
"gpt-4o-mini",
"gpt-image-1.5",
- "gpt-4o",
- "gpt-5.2",
- "gpt-5.2-pro",
]
providers.append(
{
diff --git a/src/rate_limiter.py b/src/rate_limiter.py
index 7ffd09259..4e0fe53ec 100644
--- a/src/rate_limiter.py
+++ b/src/rate_limiter.py
@@ -1,10 +1,98 @@
# src/rate_limiter.py
-"""Generic in-memory rate limiter — sliding window, keyed by IP."""
+"""Generic in-memory rate limiter — sliding window, keyed by IP.
+Also owns ``trusted_client_ip``: the single, spoof-resistant way to derive the
+client IP that every IP-keyed rate limiter in the app should share (demo caps and
+the auth-route limiters alike). Keeping one helper + one env var here avoids two
+limiters disagreeing about which ``X-Forwarded-For`` entry to trust.
+"""
+
+import logging
+import os
import threading
import time
from typing import Dict, List
+logger = logging.getLogger(__name__)
+
+
+def _trusted_proxy_hops() -> int:
+ """Number of trusted proxy hops in front of the app (see trusted_client_ip).
+
+ Read per-call from ``TRUSTED_PROXY_HOPS`` (default 1, matching Render's single
+ edge proxy) so tests and redeploys can retune it without a module reload.
+ This mirrors the conventional trusted-hop count (Werkzeug ``ProxyFix``,
+ uvicorn ``--forwarded-allow-ips``): ``n`` = ``n`` trusted proxies, and an
+ explicit ``0`` = "no trusted proxy — the deploy is directly exposed, so
+ ``X-Forwarded-For`` is entirely attacker-supplied and must be ignored in
+ favour of the real TCP peer". Unset/invalid/negative falls back to 1 (the
+ Render default). Only a deliberate ``0`` disables XFF parsing.
+ """
+ raw = os.getenv("TRUSTED_PROXY_HOPS", "").strip()
+ if not raw:
+ return 1
+ try:
+ val = int(raw)
+ except ValueError:
+ return 1
+ return val if val >= 0 else 1
+
+
+_logged_xff_sample = False
+_xff_log_lock = threading.Lock()
+
+
+def trusted_client_ip(request) -> str:
+ """Return the spoof-resistant client IP for rate limiting behind Render.
+
+ ``X-Forwarded-For`` is an ordered list; Render's edge proxy appends the real
+ peer IP to the RIGHT, so the trustworthy client IP is ``TRUSTED_PROXY_HOPS``
+ entries from the right. The leftmost entry is client-supplied and spoofable,
+ so we must NOT read it. Falls back to the immediate peer (``request.client``)
+ when the header is absent or shorter than the configured hop count.
+
+ When ``TRUSTED_PROXY_HOPS`` is ``0`` (directly-exposed deploy with no trusted
+ proxy), ``X-Forwarded-For`` is wholly attacker-supplied and is ignored: the
+ key is always the real TCP peer. Forks deploying this template off Render must
+ set ``0`` if the service is internet-facing with no proxy — leaving the
+ default ``1`` there makes the rate limiter spoofable.
+
+ NOTE: this assumes uvicorn runs WITHOUT ``--proxy-headers`` (see
+ docker/entrypoint.render.sh), so ``request.client.host`` is the Render proxy
+ and only the XFF right-side entry identifies the client. To confirm the hop
+ count on a real deploy, this logs the raw header + resolved IP exactly ONCE at
+ startup (grep the logs for ``[trusted-ip] X-Forwarded-For sample``); adjust
+ ``TRUSTED_PROXY_HOPS`` if the resolved IP isn't the true client.
+ """
+ headers = getattr(request, "headers", None)
+ xff = headers.get("x-forwarded-for", "") if headers else ""
+ hops = _trusted_proxy_hops()
+ resolved = ""
+ if xff and hops > 0:
+ parts = [p.strip() for p in xff.split(",") if p.strip()]
+ if len(parts) >= hops:
+ resolved = parts[-hops]
+ if not resolved:
+ resolved = request.client.host if getattr(request, "client", None) else ""
+
+ # One-shot observability so the hop-count assumption can be verified against
+ # real Render traffic without a redeploy. Only fires when an XFF is present.
+ global _logged_xff_sample
+ if xff and not _logged_xff_sample:
+ with _xff_log_lock:
+ if not _logged_xff_sample:
+ _logged_xff_sample = True
+ logger.info(
+ "[trusted-ip] X-Forwarded-For sample=%r hops=%s -> resolved=%r "
+ "(peer=%s). If resolved is not the true client IP, retune "
+ "TRUSTED_PROXY_HOPS.",
+ xff,
+ hops,
+ resolved,
+ request.client.host if getattr(request, "client", None) else "",
+ )
+ return resolved
+
class RateLimiter:
"""Sliding-window rate limiter.
diff --git a/src/task_scheduler.py b/src/task_scheduler.py
index d5b1dad62..9ff3fe449 100644
--- a/src/task_scheduler.py
+++ b/src/task_scheduler.py
@@ -10,6 +10,7 @@
from typing import Any, Awaitable, Callable, Dict, Tuple
from core.auth import RESERVED_USERNAMES
+from src import demo as _demo
from src.task_action_policy import (
is_admin_only_task_action,
owner_has_admin_task_privileges,
@@ -2484,7 +2485,7 @@ async def ensure_assistant_defaults(self, owner: str):
# check-ins seeded, which then double-fire alongside the human user's
# check-ins. This was the root cause of the duplicate 'Morning check-in'
# rows we had to manually clean up.
- if not owner or owner in RESERVED_USERNAMES:
+ if not owner or owner in RESERVED_USERNAMES or _demo.is_demo_owner(owner):
logger.info(f"ensure_assistant_defaults: skip synthetic owner {owner!r}")
return
from core.database import SessionLocal, CrewMember, ScheduledTask
diff --git a/static/app.js b/static/app.js
index 2f1e8d4bf..76ce5a5df 100644
--- a/static/app.js
+++ b/static/app.js
@@ -185,11 +185,16 @@ function initRailHoverLabels() {
});
}
-// Redirect to login on 401 from any fetch
+// Redirect to login on 401 from any fetch — EXCEPT in demo mode. A demo
+// visitor is intentionally unauthenticated and only the chat endpoints are
+// whitelisted server-side; every other endpoint 401s by design. Bouncing them
+// to /login would make the public demo unusable, so in demo mode we let those
+// 401s fall through and the corresponding panels simply stay empty. The flag is
+// injected synchronously into before this runs (see serve_html_with_nonce).
const _origFetch = window.fetch;
window.fetch = async function(...args) {
const res = await _origFetch.apply(this, args);
- if (res.status === 401 && !String(args[0]).includes('/api/auth/')) {
+ if (res.status === 401 && !String(args[0]).includes('/api/auth/') && !window.__ODYSSEUS_DEMO) {
window.location.href = '/login';
}
return res;
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644
index 000000000..b7abd96e9
Binary files /dev/null and b/static/favicon.ico differ
diff --git a/static/index.html b/static/index.html
index fea4e20ac..20ca892ae 100644
--- a/static/index.html
+++ b/static/index.html
@@ -4,7 +4,7 @@
Odysseus Chat
-
+
diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js
index 1d6e2e4a9..f79ba551a 100644
--- a/static/js/chatRenderer.js
+++ b/static/js/chatRenderer.js
@@ -507,6 +507,8 @@ const MODEL_INFO = {
'gpt-4.1': { input: 2.00, output: 8.00, ctx: 1047576 },
'gpt-4.1-mini': { input: 0.40, output: 1.60, ctx: 1047576 },
'gpt-4.1-nano': { input: 0.10, output: 0.40, ctx: 1047576 },
+ 'gpt-5.6-sol': { input: 5.00, output: 30.00, ctx: 1047576 },
+ 'gpt-5.6-luna': { input: 1.00, output: 6.00, ctx: 1047576 },
'gpt-4o': { input: 2.50, output: 10.00, ctx: 128000 },
'gpt-4o-mini': { input: 0.15, output: 0.60, ctx: 128000 },
'gpt-4-turbo': { input: 10.00, output: 30.00, ctx: 128000 },
diff --git a/static/js/init.js b/static/js/init.js
index 54239c52f..06e1a11e5 100644
--- a/static/js/init.js
+++ b/static/js/init.js
@@ -86,6 +86,19 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
if (_agent) _agent.style.display = 'none';
if (_chat) { _chat.classList.add('active'); _chat.click?.(); }
}
+ // Demo mode: only the chat surface is reachable server-side (every other
+ // feature 401s by design). Hide every icon-rail control EXCEPT the
+ // chat-only keep-set so the public demo shows a clean chat UI instead of
+ // buttons that silently fail. Allowlist (not denylist) so a feature rail
+ // added later is hidden by default rather than dangling a 401ing button.
+ if (data && data.demo) {
+ const _demoKeep = new Set([
+ 'rail-chats', 'rail-new-session', 'rail-theme', 'rail-resize-handle',
+ ]);
+ document.querySelectorAll('#icon-rail [id^="rail-"]').forEach(el => {
+ if (!_demoKeep.has(el.id)) el.style.display = 'none';
+ });
+ }
} catch (_) { /* DOM not ready or unexpected shape — UI gates are non-fatal */ }
} catch (_) { /* anonymous / loopback mode — nothing to do */ }
})();
diff --git a/static/login.html b/static/login.html
index eeece7cc3..de91af8c4 100644
--- a/static/login.html
+++ b/static/login.html
@@ -4,7 +4,7 @@
Odysseus — Login
-
+