+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)
----
+## What you get
+
+This Blueprint provisions three services on Render:
-## Quick Start
+| 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. |
-> `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.
+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.
-```bash
-git clone https://github.com/odysseus-dev/odysseus.git
-cd odysseus
-cp .env.example .env
-docker compose up -d --build
-```
+> 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).
-Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`.
+## Deploy
-Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](docs/setup.md).
+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.
-## Features
+### Environment variables
-- **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.
+Set these as secrets in the deploy form. All are optional per feature — you only need the keys for the features you'll use.
-## Demo
+| Variable | Needed for | Where to get it |
+|----------|-----------|-----------------|
+| `OPENAI_API_KEY` | Chat, agents, research (LLM calls) | [platform.openai.com](https://platform.openai.com/api-keys) |
+| `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) |
-A full hover-to-play tour lives on the landing page: [`docs/index.html`](docs/index.html).
+Set automatically — no action needed: `ODYSSEUS_ADMIN_PASSWORD` (generated), `SEARXNG_SECRET` (generated), plus the internal service wiring.
-## Contributing
+### Using the app
-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).
+1. Open the `odysseus` service URL once it's live.
+2. Log in as **`admin`**. Find the generated password in the Render Dashboard → the `odysseus` service → **Environment** → `ODYSSEUS_ADMIN_PASSWORD`. Change it after first login.
+3. Open **Chat** and send a message — with `OPENAI_API_KEY` set, you'll get a reply.
+4. Open **Deep Research**, enter a question, and run it. It searches the web through the bundled SearXNG (no extra key) and generates a sourced report — a good end-to-end showcase of the deploy.
-## 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..13f3debc1 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,
diff --git a/docker/entrypoint.render.sh b/docker/entrypoint.render.sh
new file mode 100644
index 000000000..42a113200
--- /dev/null
+++ b/docker/entrypoint.render.sh
@@ -0,0 +1,14 @@
+#!/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 -e
+
+# First-time setup is idempotent (creates auth.json/.env only if missing).
+# || true so a setup hiccup never blocks the container from starting.
+python /app/setup.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..5dc676dd4
--- /dev/null
+++ b/docker/searxng-render-entrypoint.sh
@@ -0,0 +1,20 @@
+#!/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
+ sed "s|__SEARXNG_SECRET__|$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
+fi
+
+exec /usr/local/searxng/entrypoint.sh
diff --git a/render.yaml b/render.yaml
new file mode 100644
index 000000000..260948656
--- /dev/null
+++ b/render.yaml
@@ -0,0 +1,98 @@
+# Render Blueprint for Odysseus — deploy the self-hosted AI workspace in one click.
+# https://render.com/docs/blueprint-spec
+#
+# Three services: 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
+# (they are stored as Render secrets, never committed).
+
+previews:
+ generation: off
+
+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: /
+ 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
+ - 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: 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
+
+ # ---- 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/searxng.Dockerfile b/searxng.Dockerfile
new file mode 100644
index 000000000..81941cf23
--- /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 /tmp/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/static/favicon.ico b/static/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..b7abd96e9569928504f024db4c50cba4e671f33c
GIT binary patch
literal 1801
zcmah|c{CgN7XBrXNQKBqUa3++X!VSx#u`*(8HpvfSO>K<#dL~NOA#eiQCdqFMq9Ov
zt!im&t)-1_n!c#1p|vLJd8L*%LQ6>*o&NLAJMWx#&$-L@-E;0A-}x>85C8)h4A>1k
zu!I0$^mW32!;Vk@Vs|+`y>FNd1E6m=FS$_dWTX_Oc3Bw;*~V?Rf4zV~B6h3F>P`><
z5Cp}BL`%5-gzZFyk}wyK%VLcXA?b}M(z?#t0Y1wi0rfDW)C_AXrP*b0bpxwzQl{l5
zGcYQ>luW7KtPSvp?JZISxcAMeNzCb=6_+_}$)ky|tG5qClOS+Tr5{7q0Op
z+|3~{l+tEZ5@%18jH5-^8~$ukT$037j!y$F$c@nyxAFGP}~|y(KPtM1n&od>4
z$!W~Jmzan{<)mYSlI0Z@wdFRrWQ09l4*uM@+#32_IhnMF9_A*OE*C7Y4mtT~q0{e0
zM^Tu#s?B+EtfXUgt~3apN23oKYxJ^PwZgv-_Zsq|IBWPz?_V{{9d!>~Vb~AVcdb|t
z{ch9so6Y27<IlF3!8Z@EtVu*9R`~vT7Uk_7&WF@gA1}O;{
z_Z^7c0&{rh2lJD+J8oOUFH39_gB5xZ0sY<9gZ<+T8D}4qoRi;Lj?!FWMvx8Dki_Z!Mazqu*vU2xl3B-R&S
z5&z46PsxJY~k{c7_}_F#r^xHlh(kg
zLYMxOpIWMWV=4c%u_==5Oy2nBwsT}8VhXqfwqE(~!O4zRm9EAJfR|X}}nmr@`$4g(I
zRqt;vc|kw30D#GU^U{d7(x*mKu>t&;
z@ckT_uX@j3WX8RI;@T{c8%k(qo4b8l_MS%OnNy-ET=YW(cY+|BV~ETZ8?u#i16e0(
z7R+Whc{@{*Y}C&o!G5zJ5+xYXUjolh3~gz%IHfBhC
z0Zin70JjQdW-cSJLtaglewlKq8aisP`XIm#wQmpP<)Tf4v`KeASn_JKLQF>;@&q|c
z%xlzqG2RnI^yw@pSTnL^u;E)(H;Q5u9p|wHiM?6*kdD;Iu#9C!n8Lt9w9Bm~d%`&*
z;Tw;*JO<&Ou{WYs=g!l0NP+_8+g|k$@O{DK
zqm|3~M1`6^>$7sG$`jb@o@G~*U!-NP93gp^W
zy?d5T_LRoEOFp4KXe5orcLT>?^JXx$4eC2E4g)xVSA~a?#M{N0OKY&!Vgv@Odysseus Chat
-
+
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
-
+
'
+ # Insert immediately after the opening so it runs first.
+ lower = html.lower()
+ idx = lower.find("")
+ if idx != -1:
+ cut = idx + len("")
+ html = html[:cut] + flag + html[cut:]
+ else:
+ html = flag + html
return HTMLResponse(html)
diff --git a/src/chat_helpers.py b/src/chat_helpers.py
index a8f5f54a8..51651ef63 100644
--- a/src/chat_helpers.py
+++ b/src/chat_helpers.py
@@ -42,7 +42,7 @@ 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-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..fc2212801
--- /dev/null
+++ b/src/demo.py
@@ -0,0 +1,324 @@
+"""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
+
+
+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".
+
+ NOTE: the import-failure fallback in ``core.auth.get_privileges`` deliberately
+ re-checks the ``demo-`` prefix inline WITHOUT this gate — that path fails
+ closed (locks down) when ``src.demo`` is unimportable and DEMO_MODE is
+ unknowable, which is the safe direction for a broken deploy."""
+ 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 --------------------------------------
+_rate_limiter = None
+# 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.
+if DEMO_MODE and DEMO_RATE_LIMIT_PER_MINUTE > 0:
+ from src.rate_limiter import RateLimiter
+ _rate_limiter = RateLimiter(max_requests=DEMO_RATE_LIMIT_PER_MINUTE, window_seconds=60)
+
+_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.
+ """
+ from src.rate_limiter import trusted_client_ip
+ 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..76335f65c 100644
--- a/src/document_processor.py
+++ b/src/document_processor.py
@@ -316,7 +316,7 @@ 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-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..1d3a2ed61 100644
--- a/src/model_context.py
+++ b/src/model_context.py
@@ -128,8 +128,8 @@ def is_local_endpoint(url: str) -> bool:
'gpt-4.1': 1047576,
'gpt-4.1-mini': 1047576,
'gpt-4.1-nano': 1047576,
- 'gpt-4o': 128000,
- 'gpt-4o-mini': 128000,
+ 'gpt-5.6-sol': 1047576,
+ 'gpt-5.6-luna': 1047576,
'gpt-4-turbo': 128000,
'gpt-4': 8192,
'gpt-3.5-turbo': 16385,
diff --git a/src/model_discovery.py b/src/model_discovery.py
index 4d67502c5..62d69077e 100644
--- a/src/model_discovery.py
+++ b/src/model_discovery.py
@@ -268,12 +268,10 @@ 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-codex",
- "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..939814356 100644
--- a/src/task_scheduler.py
+++ b/src/task_scheduler.py
@@ -2484,7 +2484,8 @@ 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:
+ from src.demo import is_demo_owner
+ if not owner or owner in RESERVED_USERNAMES or 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/index.html b/static/index.html
index 20ca892ae..2c88859f3 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1596,7 +1596,6 @@