diff --git a/.env.example b/.env.example index 3048acf9..0d08ce8b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 370a51bb..709c99b9 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/deeptutor/api/main.py b/deeptutor/api/main.py index cf4a764d..70e5f474 100644 --- a/deeptutor/api/main.py +++ b/deeptutor/api/main.py @@ -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 @@ -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 diff --git a/deeptutor/api/routers/auth.py b/deeptutor/api/routers/auth.py index 2e7191c5..abc5c539 100644 --- a/deeptutor/api/routers/auth.py +++ b/deeptutor/api/routers/auth.py @@ -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. diff --git a/deeptutor/api/routers/chat.py b/deeptutor/api/routers/chat.py index ec854b6e..ab55aaf9 100644 --- a/deeptutor/api/routers/chat.py +++ b/deeptutor/api/routers/chat.py @@ -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: diff --git a/deeptutor/api/routers/unified_ws.py b/deeptutor/api/routers/unified_ws.py index d298a897..ff7d823d 100644 --- a/deeptutor/api/routers/unified_ws.py +++ b/deeptutor/api/routers/unified_ws.py @@ -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). diff --git a/deeptutor/services/demo/rate_limiter.py b/deeptutor/services/demo/rate_limiter.py index c7168915..b2bf65c7 100644 --- a/deeptutor/services/demo/rate_limiter.py +++ b/deeptutor/services/demo/rate_limiter.py @@ -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``. @@ -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 @@ -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 diff --git a/render.yaml b/render.yaml index d7f2ca1f..8760ee96 100644 --- a/render.yaml +++ b/render.yaml @@ -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" diff --git a/tests/api/test_demo_visitor_isolation.py b/tests/api/test_demo_visitor_isolation.py index 03ff070c..3be9289a 100644 --- a/tests/api/test_demo_visitor_isolation.py +++ b/tests/api/test_demo_visitor_isolation.py @@ -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", {}) @@ -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 diff --git a/tests/services/demo/test_demo_ephemeral_persistence.py b/tests/services/demo/test_demo_ephemeral_persistence.py index 44347199..08b7563f 100644 --- a/tests/services/demo/test_demo_ephemeral_persistence.py +++ b/tests/services/demo/test_demo_ephemeral_persistence.py @@ -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. """ @@ -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"} @@ -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"} @@ -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"}) @@ -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"}) @@ -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( @@ -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( diff --git a/tests/services/demo/test_demo_visitor_isolation.py b/tests/services/demo/test_demo_visitor_isolation.py index 57ba8f30..7539f13f 100644 --- a/tests/services/demo/test_demo_visitor_isolation.py +++ b/tests/services/demo/test_demo_visitor_isolation.py @@ -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") @@ -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: @@ -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() diff --git a/tests/services/demo/test_rate_limiter.py b/tests/services/demo/test_rate_limiter.py index aacd7a7d..4c401d3c 100644 --- a/tests/services/demo/test_rate_limiter.py +++ b/tests/services/demo/test_rate_limiter.py @@ -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() @@ -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()) diff --git a/tests/services/demo/test_session_store_demo.py b/tests/services/demo/test_session_store_demo.py index 96c31144..f6dfe3f1 100644 --- a/tests/services/demo/test_session_store_demo.py +++ b/tests/services/demo/test_session_store_demo.py @@ -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() @@ -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() diff --git a/web/lib/demo-visitor.ts b/web/lib/demo-visitor.ts index 888eaed5..420c8cea 100644 --- a/web/lib/demo-visitor.ts +++ b/web/lib/demo-visitor.ts @@ -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`