Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ AUTH_PASSWORD_HASH=
POCKETBASE_ADMIN_PASSWORD=

# --- Demo mode (per-IP rate limits; OFF by default) ---
# Set DEMO_MODE=true only on a PUBLIC demo where visitors chat against your key,
# Set DEMO=true only on a PUBLIC demo where visitors chat against your key,
# to cap per-visitor spend. Limit overrides are optional. See README "Demo mode".
DEMO_MODE=false
DEMO=false
DEMO_RATE_LIMIT_PER_MIN=15
DEMO_RATE_LIMIT_PER_HOUR=200

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,13 @@ Each response streams live, so these all make for a quick visual demo without an

## Demo mode (per-IP rate limits)

If you host a **public demo URL** where anyone can chat against *your* provider key, unbounded use means runaway spend. Demo mode caps per-visitor (per-IP) usage so the demo is safe to leave open. It is **off by default** — local and private forks are unaffected — and only takes effect when you set `DEMO_MODE=true`.
If you host a **public demo URL** where anyone can chat against *your* provider key, unbounded use means runaway spend. Demo mode caps per-visitor (per-IP) usage so the demo is safe to leave open. It is **off by default** — local and private forks are unaffected — and only takes effect when you set `DEMO=true`.

Set these under the Render **Environment** tab (or your host environment) on the demo service:

| Variable | Default | Purpose |
|---|---|---|
| `DEMO_MODE` | `false` | Master switch. Truthy: `1`, `true`, `yes`, `on` (case-insensitive). |
| `DEMO` | `false` | Master switch. Truthy: `1`, `true`, `yes`, `on` (case-insensitive). |
| `DEMO_RATE_LIMIT_PER_MIN` | `15` | Max spending requests per IP per minute. |
| `DEMO_RATE_LIMIT_PER_HOUR` | `200` | Max spending requests per IP per hour. |

Expand Down
4 changes: 2 additions & 2 deletions deeptutor/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ async def selective_access_log(request, call_next):
)


# Demo-mode per-IP rate limiting. No-op unless DEMO_MODE is truthy, so local and
# Demo-mode per-IP rate limiting. No-op unless DEMO is truthy, so local and
# private forks are unaffected. This HTTP catch-all guards REST spenders; the
# chat WebSocket loops (/chat, /ws) enforce the same limiter per-message, since
# a WS handshake bypasses http middleware and one socket can send many LLM
Expand Down Expand Up @@ -382,7 +382,7 @@ async def demo_rate_limit(request, call_next):
)

# require_auth installs the current user; bind_demo_visitor partitions the demo
# session store per visitor (no-op unless DEMO_MODE is on). Both run on every
# session store per visitor (no-op unless DEMO is on). Both run on every
# authenticated router below.
_auth = [Depends(require_auth), Depends(bind_demo_visitor)]
# Partner data is anchored at the admin workspace (data/partners) and shared
Expand Down
2 changes: 1 addition & 1 deletion deeptutor/api/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ async def bind_demo_visitor(
) -> None:
"""Bind the per-visitor demo id from the ``X-Demo-Visitor`` header.

A no-op unless ``DEMO_MODE`` is on, so private deployments are unaffected.
A no-op unless ``DEMO`` is on, so private deployments are unaffected.
In demo mode this partitions the shared in-memory session store per visitor
(see ``get_sqlite_session_store``) so concurrent visitors don't see each
other's chat history.
Expand Down
2 changes: 1 addition & 1 deletion deeptutor/api/routers/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ async def websocket_chat(websocket: WebSocket):
await websocket.send_json({"type": "error", "message": "Message is required"})
continue

# Demo mode: cap per-IP LLM turns. No-op unless DEMO_MODE is set.
# Demo mode: cap per-IP LLM turns. No-op unless DEMO is set.
# Reject just this message and keep the socket open.
limiter = get_demo_limiter()
if limiter.enabled:
Expand Down
2 changes: 1 addition & 1 deletion deeptutor/api/routers/unified_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ async def unified_websocket(ws: WebSocket) -> None:
subscription_tasks: dict[str, asyncio.Task[None]] = {}

async def demo_rate_limited() -> bool:
"""Demo mode: cap per-IP LLM turns. No-op unless DEMO_MODE is set.
"""Demo mode: cap per-IP LLM turns. No-op unless DEMO is set.

On deny, sends this file's error frame convention and returns True so
the caller skips starting the turn (the socket stays open).
Expand Down
8 changes: 4 additions & 4 deletions deeptutor/services/demo/rate_limiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@

Configuration (read once from the environment)
-----------------------------------------------
* ``DEMO_MODE`` — bool, default ``false``. Truthy values (case-insensitive):
* ``DEMO`` — bool, default ``false``. Truthy values (case-insensitive):
``1``, ``true``, ``yes``, ``on``. **Off by default**, so local and private
forks are unaffected; only the public demo service sets ``DEMO_MODE=true``.
forks are unaffected; only the public demo service sets ``DEMO=true``.
* ``DEMO_RATE_LIMIT_PER_MIN`` — int, default ``15``.
* ``DEMO_RATE_LIMIT_PER_HOUR`` — int, default ``200``.

Expand Down Expand Up @@ -132,7 +132,7 @@ def __init__(
per_hour: int | None = None,
time_fn: Callable[[], float] = time.monotonic,
) -> None:
self._enabled = _env_bool("DEMO_MODE") if enabled is None else enabled
self._enabled = _env_bool("DEMO") if enabled is None else enabled
self._per_min = _env_int("DEMO_RATE_LIMIT_PER_MIN", 15) if per_min is None else per_min
self._per_hour = _env_int("DEMO_RATE_LIMIT_PER_HOUR", 200) if per_hour is None else per_hour
self._time_fn = time_fn
Expand Down Expand Up @@ -213,7 +213,7 @@ def get_demo_limiter() -> DemoRateLimiter:


def is_demo_mode() -> bool:
"""True when ``DEMO_MODE`` is enabled.
"""True when ``DEMO`` is enabled.

Shared guard for the demo's "history stays ephemeral" contract: any code
path that would otherwise write conversation-derived state to disk checks
Expand Down
4 changes: 2 additions & 2 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ services:
value: UTC

# --- Demo mode (per-IP rate limits) -----------------------------------
# OFF by default. Set DEMO_MODE=true only on a PUBLIC demo service where
# OFF by default. Set DEMO=true only on a PUBLIC demo service where
# anyone can chat against your key, to cap per-visitor spend. See the
# README "Demo mode" section. The two limit overrides are optional
# (defaults: 15/min, 200/hour per IP).
# - key: DEMO_MODE
# - key: DEMO
# value: "true"
# - key: DEMO_RATE_LIMIT_PER_MIN
# value: "15"
Expand Down
4 changes: 2 additions & 2 deletions tests/api/test_demo_visitor_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

@pytest.fixture
def demo_client(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
monkeypatch.setenv("DEMO_MODE", "true")
monkeypatch.setenv("DEMO", "true")
monkeypatch.setattr(rate_limiter, "_limiter", None)
monkeypatch.setattr(sqlite_store, "_instances", {})

Expand Down Expand Up @@ -81,7 +81,7 @@ def test_missing_header_does_not_inherit_previous_visitor(demo_client):
async def test_ws_require_auth_binds_visitor_from_query_param(monkeypatch):
"""The chat WebSocket carries the visitor id as a query param (WS upgrades
can't set custom headers), and ``ws_require_auth`` must bind it."""
monkeypatch.setenv("DEMO_MODE", "true")
monkeypatch.setenv("DEMO", "true")
monkeypatch.setattr(rate_limiter, "_limiter", None)

from deeptutor.api.routers.auth import ws_require_auth
Expand Down
14 changes: 7 additions & 7 deletions tests/services/demo/test_demo_ephemeral_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
chat-history DB in-memory, but the turn runtime and the memory subsystem write
conversation-derived state to disk through other paths. In demo mode those must
be skipped too, otherwise history is still persisted on disk. Each test below
pins the culprit write path and asserts it is a no-op when ``DEMO_MODE`` is on
pins the culprit write path and asserts it is a no-op when ``DEMO`` is on
and still writes when it is off.
"""

Expand Down Expand Up @@ -49,7 +49,7 @@ def _execution() -> _TurnExecution:


def test_workspace_event_mirror_skipped_in_demo(tmp_paths, monkeypatch):
monkeypatch.setenv("DEMO_MODE", "true")
monkeypatch.setenv("DEMO", "true")

TurnRuntimeManager._mirror_event_to_workspace(
_execution(), {"type": "assistant", "content": "secret history"}
Expand All @@ -60,7 +60,7 @@ def test_workspace_event_mirror_skipped_in_demo(tmp_paths, monkeypatch):


def test_workspace_event_mirror_written_when_demo_off(tmp_paths, monkeypatch):
monkeypatch.delenv("DEMO_MODE", raising=False)
monkeypatch.delenv("DEMO", raising=False)

TurnRuntimeManager._mirror_event_to_workspace(
_execution(), {"type": "assistant", "content": "kept history"}
Expand All @@ -73,7 +73,7 @@ def test_workspace_event_mirror_written_when_demo_off(tmp_paths, monkeypatch):

@pytest.mark.asyncio
async def test_memory_trace_append_skipped_in_demo(tmp_paths, monkeypatch):
monkeypatch.setenv("DEMO_MODE", "true")
monkeypatch.setenv("DEMO", "true")

await memory_trace.append(
memory_trace.TraceEvent.new("chat", "preference_stated", {"text": "likes X"})
Expand All @@ -85,7 +85,7 @@ async def test_memory_trace_append_skipped_in_demo(tmp_paths, monkeypatch):

@pytest.mark.asyncio
async def test_memory_trace_append_written_when_demo_off(tmp_paths, monkeypatch):
monkeypatch.delenv("DEMO_MODE", raising=False)
monkeypatch.delenv("DEMO", raising=False)

await memory_trace.append(
memory_trace.TraceEvent.new("chat", "preference_stated", {"text": "likes X"})
Expand All @@ -97,7 +97,7 @@ async def test_memory_trace_append_written_when_demo_off(tmp_paths, monkeypatch)

@pytest.mark.asyncio
async def test_write_preference_skipped_in_demo(tmp_paths, monkeypatch):
monkeypatch.setenv("DEMO_MODE", "true")
monkeypatch.setenv("DEMO", "true")

store = memory_store.MemoryStore()
report = await store.write_preference(
Expand All @@ -112,7 +112,7 @@ async def test_write_preference_skipped_in_demo(tmp_paths, monkeypatch):

@pytest.mark.asyncio
async def test_write_preference_written_when_demo_off(tmp_paths, monkeypatch):
monkeypatch.delenv("DEMO_MODE", raising=False)
monkeypatch.delenv("DEMO", raising=False)

store = memory_store.MemoryStore()
report = await store.write_preference(
Expand Down
6 changes: 3 additions & 3 deletions tests/services/demo/test_demo_visitor_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def demo_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):

@pytest.mark.asyncio
async def test_history_isolated_between_visitors(demo_env, monkeypatch):
monkeypatch.setenv("DEMO_MODE", "true")
monkeypatch.setenv("DEMO", "true")

# Visitor A starts a chat.
token_a = set_visitor_id("visitor-a")
Expand All @@ -64,7 +64,7 @@ async def test_history_isolated_between_visitors(demo_env, monkeypatch):


def test_same_visitor_reuses_one_store(demo_env, monkeypatch):
monkeypatch.setenv("DEMO_MODE", "true")
monkeypatch.setenv("DEMO", "true")

token = set_visitor_id("visitor-a")
try:
Expand All @@ -74,7 +74,7 @@ def test_same_visitor_reuses_one_store(demo_env, monkeypatch):


def test_non_demo_ignores_visitor_id(demo_env, monkeypatch):
monkeypatch.delenv("DEMO_MODE", raising=False)
monkeypatch.delenv("DEMO", raising=False)

token_a = set_visitor_id("visitor-a")
store_a = get_sqlite_session_store()
Expand Down
12 changes: 6 additions & 6 deletions tests/services/demo/test_rate_limiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,26 +99,26 @@ def test_disabled_always_allows():


def test_disabled_by_default_when_env_unset(monkeypatch):
monkeypatch.delenv("DEMO_MODE", raising=False)
limiter = DemoRateLimiter() # reads env: DEMO_MODE unset -> disabled
monkeypatch.delenv("DEMO", raising=False)
limiter = DemoRateLimiter() # reads env: DEMO unset -> disabled
assert limiter.enabled is False
assert limiter.hit("ip").allowed


@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on", "On"])
def test_env_truthy_enables(monkeypatch, value):
monkeypatch.setenv("DEMO_MODE", value)
monkeypatch.setenv("DEMO", value)
assert DemoRateLimiter().enabled is True


@pytest.mark.parametrize("value", ["0", "false", "no", "off", "", "maybe"])
def test_env_falsy_disables(monkeypatch, value):
monkeypatch.setenv("DEMO_MODE", value)
monkeypatch.setenv("DEMO", value)
assert DemoRateLimiter().enabled is False


def test_env_limits_parsed(monkeypatch):
monkeypatch.setenv("DEMO_MODE", "true")
monkeypatch.setenv("DEMO", "true")
monkeypatch.setenv("DEMO_RATE_LIMIT_PER_MIN", "2")
monkeypatch.setenv("DEMO_RATE_LIMIT_PER_HOUR", "9")
clock = _FakeClock()
Expand All @@ -129,7 +129,7 @@ def test_env_limits_parsed(monkeypatch):


def test_env_bad_limits_fall_back_to_defaults(monkeypatch):
monkeypatch.setenv("DEMO_MODE", "true")
monkeypatch.setenv("DEMO", "true")
monkeypatch.setenv("DEMO_RATE_LIMIT_PER_MIN", "not-an-int")
monkeypatch.setenv("DEMO_RATE_LIMIT_PER_HOUR", "-5")
limiter = DemoRateLimiter(time_fn=_FakeClock())
Expand Down
4 changes: 2 additions & 2 deletions tests/services/demo/test_session_store_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def reset_singletons(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):


def test_demo_mode_returns_in_memory_store(reset_singletons, monkeypatch):
monkeypatch.setenv("DEMO_MODE", "true")
monkeypatch.setenv("DEMO", "true")

store = get_sqlite_session_store()

Expand All @@ -49,7 +49,7 @@ def test_demo_mode_returns_in_memory_store(reset_singletons, monkeypatch):


def test_demo_mode_off_returns_on_disk_store(reset_singletons, monkeypatch):
monkeypatch.delenv("DEMO_MODE", raising=False)
monkeypatch.delenv("DEMO", raising=False)

store = get_sqlite_session_store()

Expand Down
2 changes: 1 addition & 1 deletion web/lib/demo-visitor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Per-visitor id for the public demo.
//
// DeepTutor is single-user by design, so in DEMO_MODE the backend serves one
// DeepTutor is single-user by design, so in DEMO the backend serves one
// shared in-memory session store to every anonymous visitor. To keep each
// visitor's chat history private, the browser mints a random id once, persists
// it in localStorage, and sends it on every request (the `X-Demo-Visitor`
Expand Down
Loading