From 53875da1a4087d304bec10533a820e8bb439dace Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Fri, 17 Jul 2026 15:51:32 -0700 Subject: [PATCH 01/19] feat(render): add one-click Render deploy template Blueprint (render.yaml) provisioning the Odysseus web app plus bundled private SearXNG and ChromaDB services, with a slim hosted Dockerfile, generated admin password, secrets as sync:false, and same-origin CORS. - Dockerfile.render: slim image (drops GPU/local-model/host-Docker tooling) - searxng.Dockerfile + entrypoint: bake settings.yml (json format) + secret - entrypoint.render.sh: bind $PORT, run first-time setup - app.py: default ALLOWED_ORIGINS to RENDER_EXTERNAL_URL when unset - README: hermes-style Render template README + Deploy button - favicon: white Render logo as base favicon Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 0bf3cc2af3d51cf90fa62d6e9a78c771f0008405) --- Dockerfile.render | 38 +++++++++++ README.md | 85 +++++++++++------------- app.py | 7 +- docker/entrypoint.render.sh | 14 ++++ docker/searxng-render-entrypoint.sh | 20 ++++++ render.yaml | 98 ++++++++++++++++++++++++++++ searxng.Dockerfile | 17 +++++ static/favicon.ico | Bin 0 -> 1801 bytes static/index.html | 2 +- static/login.html | 2 +- 10 files changed, 233 insertions(+), 50 deletions(-) create mode 100644 Dockerfile.render create mode 100644 docker/entrypoint.render.sh create mode 100644 docker/searxng-render-entrypoint.sh create mode 100644 render.yaml create mode 100644 searxng.Dockerfile create mode 100644 static/favicon.ico diff --git a/Dockerfile.render b/Dockerfile.render new file mode 100644 index 000000000..1892f6b5a --- /dev/null +++ b/Dockerfile.render @@ -0,0 +1,38 @@ +# Slim image for hosting Odysseus on Render (render.com). +# +# Deliberately drops the local-model / GPU / image-upscaling / host-Docker +# tooling from the main Dockerfile (Real-ESRGAN wheel build, torch, opencv, +# cmake, build-essential, nodejs/npm, the Docker CLI) — none of it runs on +# Render's managed platform — so builds are fast and the image stays small. +# Core chat, agents, research, documents, email, notes, and calendar (via cloud +# LLM APIs, SearXNG web search, and ChromaDB) are unaffected. +FROM python:3.14-slim + +# Runtime shared libs only: +# libmagic1 -> python-magic content-based MIME sniffing (src/upload_handler.py) +# libgomp1 -> onnxruntime, used by fastembed for local ONNX embeddings +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmagic1 \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python deps first for layer caching. +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# python-magic resolves libmagic at import time; keep it image-only (paired with +# the libmagic1 system lib installed above) exactly as the main Dockerfile does. +RUN pip install --no-cache-dir python-magic==0.4.27 + +# Copy app code. +COPY . . + +# Data / log / cache dirs. /app/data is backed by a persistent Render disk. +RUN mkdir -p data logs services/cache/search + +COPY docker/entrypoint.render.sh /usr/local/bin/entrypoint.render.sh +RUN chmod +x /usr/local/bin/entrypoint.render.sh + +ENTRYPOINT ["/usr/local/bin/entrypoint.render.sh"] diff --git a/README.md b/README.md index 705ec6b68..97ba34167 100644 --- a/README.md +++ b/README.md @@ -2,75 +2,66 @@ Odysseus

-

- A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows. -

+# Odysseus on Render -

- Quick Start · - Setup Guide · - Contributing · - Roadmap -

+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. -

- Packaging status -

+[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/Ho1yShif/odysseus)

Odysseus interface

---- +## 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 - - - - - Star History Chart - - +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 @@

-

Odysseus interface From 7da12196d8eefca64c992d28cc17b4aabb4768a9 Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Mon, 20 Jul 2026 15:43:46 -0700 Subject: [PATCH 13/19] chore: correct deploy button link (cherry picked from commit db5432b6e390abc48b1108437e79635945728880) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f049ea899..4e910afc5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 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. -[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/render-examples/odysseus) +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/render-examples/odysseus-render)

Odysseus interface From d00dc1f6db811e7b331675aa24fe136ad2a5c47f Mon Sep 17 00:00:00 2001 From: Shifra Williams Date: Mon, 20 Jul 2026 15:50:22 -0700 Subject: [PATCH 14/19] Update README by removing image and adding link Removed image tag from README and added a link. (cherry picked from commit 1459eb1d21a9cc534cc3e72cf4fb1afb16664dd0) --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 4e910afc5..8a635080f 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,7 @@ Deploy **Odysseus** on Render in one click. Get a self-hosted AI workspace — c [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/render-examples/odysseus-render) -

- Odysseus interface -

+https://github.com/user-attachments/assets/53277926-7b65-4687-8a0a-42878bd549a8 ## What you get From d2ff539df75baf444f443b9356f431b76db3f78b Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Mon, 20 Jul 2026 18:26:12 -0700 Subject: [PATCH 15/19] docs(README): add architecture section with service diagram Expanded the README to include a new architecture section detailing the service layout and interactions. Added a diagram illustrating the public and private services, enhancing clarity on how the application components communicate over Render's private network. (cherry picked from commit 7d8fbd4f84bdd785b14712ad5643254b01576def) --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 8a635080f..3f0bbb0cd 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,27 @@ This Blueprint provisions three services on Render: 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 · … │ + └────────────────────────────────────────┘ +``` + > 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 From 07ecf7720fb4063834125d4922430577725bb214 Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Mon, 20 Jul 2026 18:31:02 -0700 Subject: [PATCH 16/19] chore: fix arrow alignment in readme (cherry picked from commit 9eec59ff68b102b323e6525ddf565df1809d20ee) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3f0bbb0cd..e5ee3fc57 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Only `odysseus` is public. It reaches the two helper services over Render's priv │ disk: /app/data │ └──────┬───────────────┬──────┘ │ private │ private - ┌──────▼──────┐ ┌──────▼───────────┐ + ┌──────▼──────┐ ┌─────▼────────────┐ │ searxng │ │ chromadb │ │ web search │ │ vector store │ └──────┬──────┘ └──────────────────┘ From e8a5fa41f113743fd6f9cf7f3e3f9bba72a61a66 Mon Sep 17 00:00:00 2001 From: Shifra Williams Date: Tue, 21 Jul 2026 14:23:23 -0700 Subject: [PATCH 17/19] chore(deps): move test tooling out of the production image (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): move test tooling out of the production image pytest, pytest-asyncio, and httpx2 were in requirements.txt, so the hosted Render image (Dockerfile.render installs requirements.txt) shipped test-only tooling even though tests/ is excluded from the build context — pure bloat. Move them to a new requirements-dev.txt and install it where tests actually run (CI + the CONTRIBUTING manual-dev path). Production requirements.txt and both Dockerfiles now install runtime deps only; pins are unchanged and the full suite still collects (4655 tests). Co-Authored-By: Claude Opus 4.8 (1M context) * ci: run tests on Python 3.14 to match the production runtime CI pinned Python 3.11, but the pinned deps target 3.14 (both Dockerfiles use python:3.14-slim) — numpy==2.5.1 requires >=3.12, so `pip install -r requirements.txt` failed at install time on 3.11 and the pytest job never ran. Bump both the syntax and test jobs to 3.14 so CI exercises the versions that actually ship. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(README): reference the orphaned workspace screenshot docs/odysseus-browser.jpg was committed as a README screenshot ("Refresh README screenshot") but never wired into any doc, so test_no_orphan_images_in_docs flagged it as an orphan. Add it to the README as a product screenshot below the demo clip — fulfilling its original intent instead of deleting it — which greens the docs-hygiene test. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) (cherry picked from commit 6696373557296370fb5bce91c1118ef9313d7adb) --- .github/workflows/ci.yml | 6 +++--- CONTRIBUTING.md | 2 +- README.md | 4 ++++ requirements-dev.txt | 11 +++++++++++ requirements.txt | 8 ++------ 5 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 requirements-dev.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7d3659e8..49470917f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: "3.11" + python-version: "3.14" # Byte-compile sources — catches syntax errors without installing deps. - run: python -m compileall -q app.py core routes src services scripts tests @@ -138,9 +138,9 @@ jobs: - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 if: steps.docs-check.outputs.docs_only != 'true' with: - python-version: "3.11" + python-version: "3.14" cache: pip - - run: pip install -r requirements.txt + - run: pip install -r requirements.txt -r requirements-dev.txt if: steps.docs-check.outputs.docs_only != 'true' - run: mkdir -p data # sqlite DB lives at ./data/app.db if: steps.docs-check.outputs.docs_only != 'true' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38586845f..3bd08c6bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,7 @@ Manual development uses Python 3.11+: ```bash python3 -m venv venv source venv/bin/activate -pip install -r requirements.txt +pip install -r requirements.txt -r requirements-dev.txt # drop -dev to run the app without test tooling python -m uvicorn app:app --host 127.0.0.1 --port 7000 ``` diff --git a/README.md b/README.md index e5ee3fc57..44661d901 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ Deploy **Odysseus** on Render in one click. Get a self-hosted AI workspace — c https://github.com/user-attachments/assets/53277926-7b65-4687-8a0a-42878bd549a8 +

+ The Odysseus workspace — chat composer with the sidebar of tools: chat, email, calendar, deep research, notes, tasks, and more +

+ ## What you get This Blueprint provisions three services on Render: 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 a5de2f269..458e64119 100644 --- a/requirements.txt +++ b/requirements.txt @@ -48,9 +48,5 @@ mcp==1.28.1 pyotp==2.10.0 qrcode[pil]==8.2 croniter==6.2.4 -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` above; this is test-client only. -httpx2==2.7.0 +# Test/dev tooling (pytest, pytest-asyncio, httpx2) lives in requirements-dev.txt +# so it stays out of the production image — see that file. From ba09412872c348d32a859102d28519e8c183611a Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Mon, 10 Aug 2026 19:23:04 -0700 Subject: [PATCH 18/19] feat: implement demo mode session management and rate limiting --- app.py | 8 ++- core/auth.py | 18 +++--- core/session_manager.py | 25 ++++++-- docker/seed_openai_endpoint.py | 114 ++++++++++++++++++--------------- src/demo.py | 12 ++-- 5 files changed, 104 insertions(+), 73 deletions(-) diff --git a/app.py b/app.py index 1a580dfc2..45c60724c 100644 --- a/app.py +++ b/app.py @@ -1075,7 +1075,13 @@ async def _startup_event(): # 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. - _ghosts += _db.query(_DbSess).filter(_DbSess.owner.like("demo-%")).all() + # + # 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_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) diff --git a/core/auth.py b/core/auth.py index 92a7cda00..73dea830b 100644 --- a/core/auth.py +++ b/core/auth.py @@ -18,6 +18,9 @@ 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 + # One-shot guard so a broken ``src.demo`` import doesn't spam the log: get_privileges # is hot (status, list_users) and runs for every user, demo or not. Double-checked # under a lock so concurrent callers log the warning once, not once-per-thread @@ -25,10 +28,6 @@ _logged_demo_import_fail = False _demo_import_fail_lock = threading.Lock() - -from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402 -from core.middleware import INTERNAL_TOOL_USER # noqa: E402 - DEFAULT_PRIVILEGES = { "can_use_agent": True, "can_use_browser": True, @@ -50,6 +49,12 @@ # 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 +# backwards for this sentinel. +ADMIN_PRIVILEGES["block_all_models"] = False + # Fail-closed profile for a demo owner when src.demo (and thus DEMO_PRIVILEGES) # can't be imported at the get_privileges choke point. Defined here so it # survives that import failure. Mirrors DEMO_PRIVILEGES's intent — every @@ -61,11 +66,6 @@ DEMO_FALLBACK_PRIVILEGES = {k: (False if isinstance(v, bool) else (0 if isinstance(v, int) else [])) for k, v in DEFAULT_PRIVILEGES.items()} DEMO_FALLBACK_PRIVILEGES["allowed_models_restricted"] = True DEMO_FALLBACK_PRIVILEGES["block_all_models"] = True -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 -# backwards for this sentinel. -ADMIN_PRIVILEGES["block_all_models"] = False from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH DEFAULT_AUTH_PATH = AUTH_FILE diff --git a/core/session_manager.py b/core/session_manager.py index 88efda82b..3a04429f4 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 @@ -24,6 +25,13 @@ logger = logging.getLogger(__name__) +# One-shot guard so a broken ``src.demo`` import can't spam the log from the +# per-message persist path (_persist_message runs for every message written). +# Double-checked under a lock so concurrent writers log once, not once-per-thread +# (mirrors the _logged_demo_import_fail guard in core/auth.py). +_logged_demo_import_fail = False +_demo_import_fail_lock = threading.Lock() + def _message_timestamp_iso(value: Optional[datetime]) -> Optional[str]: """Return a stable ISO timestamp for chat message metadata.""" @@ -239,19 +247,24 @@ def _persist_message(self, session_id: str, message: ChatMessage): # to the import: a swallowed error would fail OPEN (persist a # stranger's chat), so fall back to the owner-prefix check inline — # matching src.demo.is_demo_owner — rather than dropping the guard. - _owner = getattr(db_session, "owner", None) + owner = getattr(db_session, "owner", None) try: from src.demo import is_demo_owner - _is_demo = is_demo_owner(_owner) + is_demo = is_demo_owner(owner) except Exception as e: - logger.warning("Demo owner check unavailable, using prefix fallback: %s", e) - _is_demo = bool(_owner) and str(_owner).startswith("demo-") - if _is_demo: + global _logged_demo_import_fail + if not _logged_demo_import_fail: + with _demo_import_fail_lock: + if not _logged_demo_import_fail: + _logged_demo_import_fail = True + logger.warning("Demo owner check unavailable, using prefix fallback: %s", e) + is_demo = bool(owner) and str(owner).startswith("demo-") + if is_demo: return missing_upload_id = reserve_message_upload_references( getattr(self, "upload_handler", None), - getattr(db_session, "owner", None), + owner, message.content, message.metadata, ) diff --git a/docker/seed_openai_endpoint.py b/docker/seed_openai_endpoint.py index f04abf57b..d8af1a90d 100644 --- a/docker/seed_openai_endpoint.py +++ b/docker/seed_openai_endpoint.py @@ -1,3 +1,4 @@ +#!/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 @@ -39,55 +40,64 @@ logging.basicConfig(level=logging.INFO, format="%(message)s") log = logging.getLogger("seed_openai_endpoint") -_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.") - raise SystemExit(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 "gpt-5.6-sol").strip() or "gpt-5.6-sol" - -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) - raise SystemExit(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() +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/src/demo.py b/src/demo.py index fc2212801..192708f77 100644 --- a/src/demo.py +++ b/src/demo.py @@ -31,6 +31,8 @@ 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.""" @@ -193,12 +195,13 @@ def apply_demo_session_config(session) -> None: # --- 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) +_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 @@ -285,7 +288,6 @@ def demo_client_ip(request) -> str: 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) From 660bc2e4191e344aaa6924f7f6ebbd05fb293036 Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Mon, 10 Aug 2026 19:40:47 -0700 Subject: [PATCH 19/19] refactor: make model-catalog edits additive and unify src.demo imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Render template port replaced gpt-4o entries across the model catalog instead of adding the newer tier alongside them, which silently regressed three things for anyone still on gpt-4o: it stopped being detected as vision-capable (src/chat_helpers.py), lost its context window so prompts truncated on the wrong boundary (src/model_context.py), and lost its pricing so the cost readout went blank (static/js/chatRenderer.js). The curated picker list, discovery list, VL auto-detect candidates, and the gpt-4o-mini-tts option had shrunk the same way. Every one of those edits is now additive — the new IDs are added, the existing entries stay. src.demo was imported three inconsistent ways: unconditionally at module top in app.py and chat_routes, lazily-with-fallback in core/auth.py and core/session_manager.py, and lazily-unguarded in assistant_routes and task_scheduler. The module-top imports meant the fallbacks could never fire in a running app — src.demo failing to import takes the boot down first — so they were dead code carrying real complexity, including two double-checked one-shot log guards. src.demo is a leaf module (stdlib plus src.rate_limiter), so there was never a cycle to dodge. Every caller now imports it at module top, and the fallbacks are gone. Callers reference it as a module rather than importing names, so values like DEMO_MODE, DEMO_MODEL, and DEMO_PRIVILEGES are read through it: a `from src.demo import DEMO_MODE` binds the value and goes stale whenever the module is reloaded, which the test suite does to toggle the flag. Also: serve_html_with_nonce matched a literal "", so a template with attributes on the tag would fall through to prepending the demo flag ahead of — after the fetch wrapper it exists to gate. It now matches the tag with a regex and 500s if there is no at all, rather than serving a demo page that bounces visitors to /login. And the README deploy button still pointed at render-examples/odysseus-render, the mis-forked repo this one replaces. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- app.py | 21 +++++------------ core/auth.py | 47 +++++++------------------------------ core/session_manager.py | 43 +++++++-------------------------- routes/assistant_routes.py | 4 ++-- routes/auth_routes.py | 34 ++++++++++----------------- routes/chat_routes.py | 23 +++++++----------- routes/model_routes.py | 12 ++++++---- routes/session_routes.py | 10 ++++---- src/app_helpers.py | 24 ++++++++++++------- src/chat_helpers.py | 3 ++- src/demo.py | 8 +++---- src/document_processor.py | 3 ++- src/model_context.py | 2 ++ src/model_discovery.py | 4 ++++ src/task_scheduler.py | 4 ++-- static/index.html | 1 + static/js/chatRenderer.js | 2 ++ static/js/model/matchKey.js | 4 ++-- tests/test_demo_mode.py | 23 ------------------ 20 files changed, 95 insertions(+), 179 deletions(-) diff --git a/README.md b/README.md index 44661d901..55c4c7921 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 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. -[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/render-examples/odysseus-render) +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/Ho1yShif/odysseus) https://github.com/user-attachments/assets/53277926-7b65-4687-8a0a-42878bd549a8 diff --git a/app.py b/app.py index 45c60724c..ba7103644 100644 --- a/app.py +++ b/app.py @@ -262,12 +262,7 @@ async def dispatch(self, request, call_next): # 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.demo import ( - DEMO_MODE, - is_demo_allowed as _demo_route_allowed, - resolve_demo_owner as _resolve_demo_owner, - set_demo_cookie as _set_demo_cookie, -) +from src import demo as _demo if AUTH_ENABLED: AUTH_EXEMPT_EXACT = { @@ -490,14 +485,14 @@ def _do(): # 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_MODE and _demo_route_allowed(request.method, path): - owner, new_cookie = _resolve_demo_owner(request) + 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: - _set_demo_cookie(response, new_cookie) + _demo.set_demo_cookie(response, new_cookie) return response if path.startswith("/api/"): @@ -1057,11 +1052,7 @@ async def _startup_event(): 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. - try: - from src.demo import log_startup_mode - log_startup_mode(logger) - except Exception as e: - logger.warning("Failed to log demo startup mode: %s", e) + _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. @@ -1080,7 +1071,7 @@ async def _startup_event(): # 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_MODE: + 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() diff --git a/core/auth.py b/core/auth.py index 73dea830b..c746986ac 100644 --- a/core/auth.py +++ b/core/auth.py @@ -20,13 +20,11 @@ from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402 from core.middleware import INTERNAL_TOOL_USER # noqa: E402 - -# One-shot guard so a broken ``src.demo`` import doesn't spam the log: get_privileges -# is hot (status, list_users) and runs for every user, demo or not. Double-checked -# under a lock so concurrent callers log the warning once, not once-per-thread -# (mirrors the _logged_xff_sample guard in src/rate_limiter.py). -_logged_demo_import_fail = False -_demo_import_fail_lock = threading.Lock() +# 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, @@ -55,18 +53,6 @@ # backwards for this sentinel. ADMIN_PRIVILEGES["block_all_models"] = False -# Fail-closed profile for a demo owner when src.demo (and thus DEMO_PRIVILEGES) -# can't be imported at the get_privileges choke point. Defined here so it -# survives that import failure. Mirrors DEMO_PRIVILEGES's intent — every -# capability off — but errs harder (blocks all models): this path should never -# be reached, and if it is we deny everything rather than fall through to the -# more-permissive DEFAULT_PRIVILEGES. Derived from DEFAULT_PRIVILEGES (every -# bool→False, int→0, list→[]) so a newly added privilege key defaults to off -# here automatically instead of silently inheriting the permissive default. -DEMO_FALLBACK_PRIVILEGES = {k: (False if isinstance(v, bool) else (0 if isinstance(v, int) else [])) for k, v in DEFAULT_PRIVILEGES.items()} -DEMO_FALLBACK_PRIVILEGES["allowed_models_restricted"] = True -DEMO_FALLBACK_PRIVILEGES["block_all_models"] = True - from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH DEFAULT_AUTH_PATH = AUTH_FILE TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days @@ -405,26 +391,9 @@ 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. Lazy - # import keeps src.demo from importing core.auth (cycle). Fail CLOSED: if - # the import breaks, a demo owner must NOT fall through to the - # more-permissive DEFAULT_PRIVILEGES, so detect the demo- prefix inline - # (mirroring src.demo.is_demo_owner, as core/session_manager.py does) and - # return the locked-down fallback instead. - try: - from src.demo import is_demo_owner, DEMO_PRIVILEGES - except Exception as e: - global _logged_demo_import_fail - if not _logged_demo_import_fail: - with _demo_import_fail_lock: - if not _logged_demo_import_fail: - _logged_demo_import_fail = True - logger.warning("Demo privilege import failed; applying locked-down fallback: %s", e) - if bool(username) and str(username).startswith("demo-"): - return {**DEFAULT_PRIVILEGES, **DEMO_FALLBACK_PRIVILEGES} - else: - if is_demo_owner(username): - return {**DEFAULT_PRIVILEGES, **DEMO_PRIVILEGES} + # 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 3a04429f4..134cafe0b 100644 --- a/core/session_manager.py +++ b/core/session_manager.py @@ -18,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 @@ -25,13 +26,6 @@ logger = logging.getLogger(__name__) -# One-shot guard so a broken ``src.demo`` import can't spam the log from the -# per-message persist path (_persist_message runs for every message written). -# Double-checked under a lock so concurrent writers log once, not once-per-thread -# (mirrors the _logged_demo_import_fail guard in core/auth.py). -_logged_demo_import_fail = False -_demo_import_fail_lock = threading.Lock() - def _message_timestamp_iso(value: Optional[datetime]) -> Optional[str]: """Return a stable ISO timestamp for chat message metadata.""" @@ -242,24 +236,9 @@ def _persist_message(self, session_id: str, message: ChatMessage): 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. Lazy import avoids a src.demo -> core cycle. Scope the guard - # to the import: a swallowed error would fail OPEN (persist a - # stranger's chat), so fall back to the owner-prefix check inline — - # matching src.demo.is_demo_owner — rather than dropping the guard. + # cache only and never write a stranger's chat to the deployer's disk. owner = getattr(db_session, "owner", None) - try: - from src.demo import is_demo_owner - is_demo = is_demo_owner(owner) - except Exception as e: - global _logged_demo_import_fail - if not _logged_demo_import_fail: - with _demo_import_fail_lock: - if not _logged_demo_import_fail: - _logged_demo_import_fail = True - logger.warning("Demo owner check unavailable, using prefix fallback: %s", e) - is_demo = bool(owner) and str(owner).startswith("demo-") - if is_demo: + if _demo.is_demo_owner(owner): return missing_upload_id = reserve_message_upload_references( @@ -477,17 +456,11 @@ def sync_session_metadata(self, session_id: str) -> bool: # 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). Lazy import avoids a src.demo -> core cycle. - try: - from src.demo import is_demo_owner, apply_demo_session_config - if is_demo_owner(session.owner): - apply_demo_session_config(session) - except Exception as e: - logger.warning( - "Demo session-config pin failed for %s; leaving client values: %s", - session_id, - e, - ) + # 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/routes/assistant_routes.py b/routes/assistant_routes.py index 1ea2348f8..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,8 +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.""" - from src.demo import is_demo_owner - if not owner or owner in RESERVED_USERNAMES or is_demo_owner(owner): + 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 ad73fbe61..33e460fb9 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -14,6 +14,7 @@ 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 import demo as _demo from src.rate_limiter import RateLimiter, trusted_client_ip from src.settings_scrub import scrub_settings from src.settings import ( @@ -189,27 +190,18 @@ async def auth_status(request: Request, response: Response): # 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. - try: - from src.demo import ( - DEMO_MODE, - resolve_demo_owner, - is_demo_owner, - set_demo_cookie, - ) - if DEMO_MODE and not result.get("authenticated"): - owner, new_cookie = resolve_demo_owner(request) - if is_demo_owner(owner): - if new_cookie: - 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 - except Exception as e: - logger.warning("Demo status resolution failed; falling back: %s", e) + 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 5e4dd6e0f..5e1fa1311 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -43,14 +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.demo import ( - is_demo_owner, - check_demo_limits, - demo_client_ip, - demo_limit_sse, - clamp_demo_output_tokens, - apply_demo_session_config, -) +from src import demo as _demo from src.tool_policy import ( WEB_TOOL_NAMES, build_effective_tool_policy, @@ -892,17 +885,17 @@ async def chat_stream(request: Request) -> StreamingResponse: # 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 is_demo_owner(owner): - _demo_msg = check_demo_limits(owner, demo_client_ip(request)) + if _demo.is_demo_owner(owner): + _demo_msg = _demo.check_demo_limits(owner, _demo.demo_client_ip(request)) if _demo_msg: return StreamingResponse( - demo_limit_sse(_demo_msg), media_type="text/event-stream" + _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. - apply_demo_session_config(sess) + _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.") @@ -1000,7 +993,7 @@ async def chat_stream(request: Request) -> StreamingResponse: # 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 is_demo_owner(owner): + if _demo.is_demo_owner(owner): chat_mode = "chat" auto_escalated = False _tool_intent = None @@ -1036,8 +1029,8 @@ async def chat_stream(request: Request) -> StreamingResponse: # 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 is_demo_owner(ctx.user): - ctx.preset.max_tokens = clamp_demo_output_tokens(ctx.preset.max_tokens) + 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 diff --git a/routes/model_routes.py b/routes/model_routes.py index 6cbda4e63..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,7 +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.6-sol", "gpt-5.6-luna", "o3", "o4-mini", + "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": [ @@ -2426,9 +2431,8 @@ def get_default_chat(request: Request): # 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. - from src.demo import is_demo_request, OPENAI_CHAT_URL, DEMO_MODEL - if is_demo_request(request, _user): - return {"endpoint_id": "", "endpoint_url": OPENAI_CHAT_URL, "model": DEMO_MODEL} + 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 1bd492947..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 @@ -349,11 +350,10 @@ def create_session( # 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. - from src.demo import is_demo_request, OPENAI_CHAT_URL, DEMO_MODEL - if is_demo_request(request, user): + if _demo.is_demo_request(request, user): endpoint_id = "" - endpoint_url = OPENAI_CHAT_URL - model = DEMO_MODEL + 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) @@ -787,7 +787,7 @@ def list_archived_sessions(request: Request, search: str = "", offset: int = 0, if model: # Contains match (mirrors the name filter above). The old # f"%{model}" was a SUFFIX-only match, so filtering by "gpt-4" - # dropped "gpt-5.6-sol" and over-matched on shared suffixes; it also + # dropped "gpt-4o" and over-matched on shared suffixes; it also # left LIKE wildcards in the user value unescaped. safe_model = model.replace('%', r'\%').replace('_', r'\_') q = q.filter(DbSession.model.ilike(f"%{safe_model}%", escape='\\')) diff --git a/src/app_helpers.py b/src/app_helpers.py index a87e56194..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: @@ -42,7 +46,8 @@ def serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse: 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. + 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: @@ -54,14 +59,15 @@ def serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse: html = html.replace("{{CSP_NONCE}}", nonce) if getattr(request.state, "is_demo", False): flag = f'' - # 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 + # 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 51651ef63..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-5.6-sol", "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 index 192708f77..185dc868f 100644 --- a/src/demo.py +++ b/src/demo.py @@ -128,10 +128,10 @@ def is_demo_owner(username: Optional[str]) -> bool: 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.""" + 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) diff --git a/src/document_processor.py b/src/document_processor.py index 76335f65c..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-5.6-sol", "gpt-5.6-luna", "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 1d3a2ed61..684309d4b 100644 --- a/src/model_context.py +++ b/src/model_context.py @@ -130,6 +130,8 @@ def is_local_endpoint(url: str) -> bool: '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, 'gpt-4': 8192, 'gpt-3.5-turbo': 16385, diff --git a/src/model_discovery.py b/src/model_discovery.py index 62d69077e..c2581ede0 100644 --- a/src/model_discovery.py +++ b/src/model_discovery.py @@ -270,7 +270,11 @@ def get_providers(self) -> Dict[str, Any]: 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", ] providers.append( diff --git a/src/task_scheduler.py b/src/task_scheduler.py index 939814356..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,8 +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. - from src.demo import is_demo_owner - if not owner or owner in RESERVED_USERNAMES or is_demo_owner(owner): + 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/index.html b/static/index.html index 2c88859f3..20ca892ae 100644 --- a/static/index.html +++ b/static/index.html @@ -1596,6 +1596,7 @@

+