diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..02181d4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# Only what the image needs: pyproject.toml, uv.lock, README.md, LICENSE, src/. +* +!pyproject.toml +!uv.lock +!README.md +!LICENSE +!src/ +**/__pycache__ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..72aa83e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# The hosted ChatLore demo: the made-up demo library behind the public web +# interface, API, and MCP endpoint. See docs/hosting.md. +# +# docker build -t chatlore-demo . +# docker run -p 7860:7860 -e OPENROUTER_API_KEY=... chatlore-demo + +FROM ghcr.io/astral-sh/uv:0.12-python3.12-trixie-slim + +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never +WORKDIR /app + +# Dependencies first, so changing the code does not reinstall them. +COPY pyproject.toml uv.lock ./ +RUN uv sync --locked --no-dev --no-install-project +COPY README.md LICENSE ./ +COPY src ./src +RUN uv sync --locked --no-dev --no-editable + +# Hugging Face Spaces and many other hosts run containers as user 1000. +RUN useradd --create-home --uid 1000 chatlore +USER chatlore +ENV HOME=/home/chatlore PATH=/app/.venv/bin:$PATH + +# Load the demo library and download the embedding model while building, so +# the container answers as soon as it starts. +RUN chatlore demo --no-serve \ + && chatlore --home "$HOME/.chatlore-demo" search --semantic "warm up" > /dev/null + +EXPOSE 7860 +# PORT is set by hosts such as Render and Fly.io; Hugging Face Spaces uses 7860. +CMD ["sh", "-c", "exec chatlore --home \"$HOME/.chatlore-demo\" serve --public --host 0.0.0.0 --port \"${PORT:-7860}\""] diff --git a/README.md b/README.md index 8f368c5..5f1ba26 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ extraction is an optional enrichment you can re-run with a better model later. | M4 | Entity, topic, and fact extraction with provenance | entities and topics done; facts later | | M5 | REST API with streaming chat | done | | M6 | Web UI: conversations, graph explorer, chat | basic version done | -| M7 | MCP server for Claude Desktop, Claude Code, Cursor, ChatGPT | done for local assistants; ChatGPT with the hosted demo | +| M7 | MCP server for Claude Desktop, Claude Code, Cursor, ChatGPT | done; ChatGPT through /mcp once the demo is online | | M8 | Easy to try: PyPI package, demo library, export and import | done, v0.1.0 | | M9 | Hosted demo | planned | | M10 | FalkorDB backend | done | diff --git a/docs/hosting.md b/docs/hosting.md new file mode 100644 index 0000000..562f9ea --- /dev/null +++ b/docs/hosting.md @@ -0,0 +1,73 @@ +# Hosting the demo + +```bash +docker build -t chatlore-demo . +docker run -p 7860:7860 -e OPENROUTER_API_KEY=sk-or-... chatlore-demo +``` + +The image in the repository's `Dockerfile` is the hosted demo: the made-up +library from `chatlore demo`, served to anyone who opens it. Visitors get the +web interface at `/`, the REST API with its docs at `/docs`, and the MCP server +at `/mcp`, so they can connect ChatGPT, Claude, or another assistant to it +without installing anything. + +The demo library and the embedding model are built into the image, so a +container answers as soon as it starts. It used about 400 MB of memory once +searches had loaded the embedding model; give it 1 GB to be safe. It listens on +port 7860, or on `$PORT` when the host sets it. + +## The model key + +Search, conversations, topics, the graph, and every MCP tool work without a +key. Only asking in the web interface or through `POST /chat` needs a language +model, and that is paid for with the host's key. Pass it as a secret, never in +the image: + +| Variable | Meaning | +|---|---| +| `OPENROUTER_API_KEY` | Key for OpenRouter, the default provider. | +| `CHATLORE_LLM_MODEL`, `CHATLORE_LLM_BASE_URL` | Another model or provider; see [models.md](models.md). | + +Without a key, asking says that a key is needed and everything else works. +OpenRouter lets a key have a credit limit; give the demo's key one. + +## Public mode + +The image runs `chatlore serve --public`. Anyone can run the same on a library +they are happy for anyone to read: + +```bash +chatlore --home ~/.chatlore-demo serve --public --host 0.0.0.0 --port 7860 +``` + +`--public` is for a server behind a proxy on the internet, such as the HTTPS +front of a hosting service: + +- Questions that reach the language model are limited: 10 an hour for each + visitor and 300 a day for everyone together, by default. Change them with + `--questions-per-hour` and `--questions-per-day`. A question that finds no + passages is not counted, since it never reaches the model. When a limit is + reached, the visitor is told why and how to install ChatLore instead. +- Visitors are told apart by the address the proxy reports, from + `X-Forwarded-For`. Without a proxy in front, visitors could claim any address, + but the daily limit still holds. +- `/mcp` accepts requests for any host name. A private server only answers + requests addressed to this machine, which keeps websites from reaching it + through a visitor's browser. +- The web interface says it is a demo, and `/health` reports `"public": true`. + +The library is only ever read. Never serve your own library this way: anyone +who finds the address can read every conversation in it. + +## Where to host it + +Any service that builds and runs a Dockerfile from a Git repository works, and +gives the container an HTTPS address. ChatGPT only connects to HTTPS. + +CI builds the image on every pull request, starts it, and checks the web +interface, the API, and a tool call over MCP. + +## Connecting assistants to it + +The MCP endpoint is the demo's address followed by `/mcp`, with no +authentication. See [mcp.md](mcp.md#over-http) for each assistant. diff --git a/docs/mcp.md b/docs/mcp.md index 3d40419..384da50 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -80,8 +80,32 @@ project, or `.cursor/mcp.json` for one. **Other clients.** Any MCP client that starts local servers works the same way: the command is `chatlore` with the argument `mcp`. ChatGPT only connects to -servers on the internet, which a private library should not be; that will come -with the hosted demo. +servers on the internet, so it uses the hosted demo, below. + +## Over HTTP + +`chatlore serve` serves the same tools at `/mcp` over streamable HTTP, for +assistants that connect to a URL instead of starting a command: + +```bash +chatlore serve # then: +claude mcp add --transport http chatlore http://127.0.0.1:8000/mcp +``` + +On your machine, `/mcp` only answers requests addressed to `127.0.0.1` or +`localhost`, so websites cannot reach it through your browser. + +The hosted demo serves its made-up library the same way at its address followed +by `/mcp`, with no authentication (see [hosting.md](hosting.md)): + +- **ChatGPT**: in developer mode, add a connector with that URL and no + authentication. +- **Claude**: add it as a custom connector, or in Claude Code with + `claude mcp add --transport http chatlore-demo `. +- **Cursor**: an `mcpServers` entry with `"url": ""` instead of a command. + +Never put your own library on the internet this way: anyone with the address +could read it. ## Tools diff --git a/src/chatlore/api.py b/src/chatlore/api.py index 69bddaf..a584071 100644 --- a/src/chatlore/api.py +++ b/src/chatlore/api.py @@ -6,28 +6,39 @@ is written, then which sources it cited. The web interface at / is built on these endpoints. +The MCP server is served at /mcp too, over streamable HTTP, for assistants +that connect to a URL instead of starting ``chatlore mcp``. + Each request opens the store and closes it again, so requests never share a database connection across threads. The server listens on 127.0.0.1 unless told -otherwise, because the library is private. +otherwise, because the library is private. A public server, such as the hosted +demo, limits how many questions reach the language model, since each one is paid +for with the host's key. """ from __future__ import annotations -from collections.abc import Iterator +import threading +import time +from collections import deque +from collections.abc import AsyncIterator, Callable, Iterator +from contextlib import asynccontextmanager from pathlib import Path from typing import Annotated, Any -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException, Query, Request from fastapi.sse import EventSourceResponse, ServerSentEvent from fastapi.staticfiles import StaticFiles +from mcp.server.transport_security import TransportSecuritySettings from pydantic import BaseModel, Field from starlette.responses import Response from starlette.types import Scope from chatlore import __version__ -from chatlore.chat import MAX_SOURCES, Context, Source, answer, cited, retrieve +from chatlore.chat import MAX_SOURCES, ChatLimits, Context, Source, answer, cited, retrieve from chatlore.embeddings import Embedder, EmbeddingError, make_embedder, normalise from chatlore.llm import LLMError, make_llm +from chatlore.mcp_server import create_server from chatlore.paths import default_home from chatlore.search import hybrid_search from chatlore.store import Edge, EdgeType, GraphStore, Label, Node, open_store @@ -49,6 +60,54 @@ async def get_response(self, path: str, scope: Scope) -> Response: return response +_HOUR = 3600.0 +_MAX_VISITORS = 10_000 + + +class _Allowance: + """Counts questions against ``ChatLimits``. Shared by the request threads.""" + + def __init__(self, limits: ChatLimits, clock: Callable[[], float] = time.time) -> None: + self.limits = limits + self._clock = clock + self._lock = threading.Lock() + self._visitors: dict[str, deque[float]] = {} + self._day = "" + self._today = 0 + + def take(self, visitor: str) -> str | None: + """Count a question from ``visitor``, or say why it cannot be asked now.""" + now = self._clock() + day = time.strftime("%Y-%m-%d", time.gmtime(now)) + with self._lock: + if day != self._day: + self._day, self._today = day, 0 + asked = self._visitors.setdefault(visitor, deque()) + while asked and asked[0] <= now - _HOUR: + asked.popleft() + if len(asked) >= self.limits.per_visitor_hour: + return ( + f"This demo answers {self.limits.per_visitor_hour} questions an hour for " + "each visitor. Try again later, or install ChatLore to ask your own " + "conversations: pip install chatlore" + ) + if self._today >= self.limits.per_day: + return ( + "This demo has answered all the questions it can today. Try again " + "tomorrow, or install ChatLore to ask your own conversations: " + "pip install chatlore" + ) + asked.append(now) + self._today += 1 + if len(self._visitors) > _MAX_VISITORS: + self._visitors = { + key: times + for key, times in self._visitors.items() + if times and times[-1] > now - _HOUR + } + return None + + class ChatRequest(BaseModel): question: str = Field(min_length=1, max_length=4000) sources: int = Field(default=MAX_SOURCES, ge=1, le=20) @@ -90,15 +149,39 @@ def _topic(node: Node, entities: int | None = None) -> dict[str, Any]: } -def create_app(home: Path | None = None) -> FastAPI: - """The API over the library in ``home``, or the default ChatLore home.""" +def create_app( + home: Path | None = None, *, public: bool = False, limits: ChatLimits | None = None +) -> FastAPI: + """The API over the library in ``home``, or the default ChatLore home. + + A ``public`` app says so at /health, which makes the web interface show a + demo note, and accepts MCP requests for any host name, since it is meant to + be reached from the internet. Otherwise /mcp only answers requests addressed + to this machine, which keeps other websites from reaching it through the + browser. ``limits`` caps the questions sent to the language model. + """ library = home or default_home() + assistants = create_server(library) + security = TransportSecuritySettings(enable_dns_rebinding_protection=False) if public else None + # Stateless, with plain JSON replies: any worker can answer any request, and + # no tool streams its result. + mcp_app = assistants.streamable_http_app( + stateless_http=True, json_response=True, transport_security=security + ) + + @asynccontextmanager + async def lifespan(_: FastAPI) -> AsyncIterator[None]: + async with assistants.session_manager.run(): + yield + app = FastAPI( title="ChatLore", version=__version__, description="All your AI conversations, one graph, one chat.", + lifespan=lifespan, ) embedders: list[Embedder] = [] + allowance = _Allowance(limits) if limits is not None else None def store() -> GraphStore: return open_store(library) @@ -115,8 +198,8 @@ def embed(text: str, graph: GraphStore) -> list[float] | None: raise HTTPException(503, str(error)) from error @app.get("/health") - def health() -> dict[str, str]: - return {"status": "ok", "version": __version__} + def health() -> dict[str, Any]: + return {"status": "ok", "version": __version__, "public": public} @app.get("/stats") def stats() -> dict[str, int]: @@ -385,7 +468,7 @@ def graph( } @app.post("/chat", response_class=EventSourceResponse) - def chat(request: ChatRequest) -> Iterator[ServerSentEvent]: + def chat(request: ChatRequest, http: Request) -> Iterator[ServerSentEvent]: """Answer a question, streaming `sources`, then `token`s, then `done` or `error`. `sources` lists the numbered passages the answer may cite. Each `token` @@ -405,6 +488,11 @@ def chat(request: ChatRequest) -> Iterator[ServerSentEvent]: if context.empty: yield ServerSentEvent(event="done", data={"cited": [], "found": False}) return + if allowance is not None: + refusal = allowance.take(http.client.host if http.client else "unknown") + if refusal is not None: + yield ServerSentEvent(event="error", data={"message": refusal}) + return try: llm = make_llm() except LLMError as error: @@ -423,6 +511,9 @@ def chat(request: ChatRequest) -> Iterator[ServerSentEvent]: numbers = [source.number for source in cited("".join(written), context)] yield ServerSentEvent(event="done", data={"cited": numbers, "found": True}) + # POST /mcp for assistants, next to the API. + app.router.routes.extend(mcp_app.routes) + # The web interface: plain files, no build step. Mounted last, so every API # route above takes precedence over a file of the same name. app.mount("/", _WebFiles(directory=WEB, html=True), name="web") diff --git a/src/chatlore/chat.py b/src/chatlore/chat.py index e86ddca..e9ff15e 100644 --- a/src/chatlore/chat.py +++ b/src/chatlore/chat.py @@ -32,6 +32,17 @@ MAX_ANSWER_TOKENS = 1500 MAX_NAME_WORDS = 4 + +@dataclass(frozen=True, slots=True) +class ChatLimits: + """How many questions a public server sends to the language model.""" + + per_visitor_hour: int = 10 + """Questions one visitor, told apart by address, may ask in any hour.""" + per_day: int = 300 + """Questions all visitors together may ask in a day, counted in UTC.""" + + _WORD = re.compile(r"[\w][\w.+#'-]*") _CITATION = re.compile(r"\[(\d+)\]") _NOT_NAMES = frozenset( diff --git a/src/chatlore/cli.py b/src/chatlore/cli.py index 872bfe0..55c974a 100644 --- a/src/chatlore/cli.py +++ b/src/chatlore/cli.py @@ -12,7 +12,7 @@ from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path -from typing import Annotated +from typing import Annotated, Any import typer from dotenv import find_dotenv, load_dotenv @@ -29,7 +29,7 @@ import_archive, is_archive, ) -from chatlore.chat import MAX_SOURCES, answer, cited, retrieve +from chatlore.chat import MAX_SOURCES, ChatLimits, answer, cited, retrieve from chatlore.embeddings import EmbeddingCache, EmbeddingError, make_embedder, normalise from chatlore.extraction import ExtractionCache, entity_key from chatlore.importers import ( @@ -717,19 +717,59 @@ def serve( port: Annotated[ int, typer.Option("--port", min=1, max=65535, help="Port to listen on.") ] = 8000, + public: Annotated[ + bool, + typer.Option( + "--public", + help=( + "Serve a public demo behind a proxy: limit the questions sent to the " + "model, take visitors' addresses from the proxy, and show a demo note." + ), + ), + ] = False, + questions_per_hour: Annotated[ + int, + typer.Option( + "--questions-per-hour", + min=1, + help="With --public: questions one visitor may ask per hour.", + ), + ] = ChatLimits().per_visitor_hour, + questions_per_day: Annotated[ + int, + typer.Option( + "--questions-per-day", + min=1, + help="With --public: questions all visitors may ask per day.", + ), + ] = ChatLimits().per_day, ) -> None: - """Serve the REST API with streaming chat. Interactive docs are at /docs.""" + """Serve the web interface, the REST API, and MCP at /mcp. API docs are at /docs.""" import uvicorn # loaded here so other commands start without the web stack from chatlore.api import create_app - if host not in _LOCAL_HOSTS: + options: dict[str, Any] = {} + if public: + limits = ChatLimits(per_visitor_hour=questions_per_hour, per_day=questions_per_day) + application = create_app(public=True, limits=limits) + # The proxy in front says who the visitor is; without it every visitor + # would share the proxy's address, and its limit. + options = {"proxy_headers": True, "forwarded_allow_ips": "*"} console.print( - "[yellow]Listening beyond this machine: anyone who can reach this address " - "can read your library.[/yellow]" + f"Public demo of {display_path(default_home())}: anyone who can reach it can read " + f"this library and ask {questions_per_hour} questions an hour each, " + f"{questions_per_day} a day in all." ) + else: + application = create_app() + if host not in _LOCAL_HOSTS: + console.print( + "[yellow]Listening beyond this machine: anyone who can reach this address " + "can read your library.[/yellow]" + ) console.print(f"ChatLore API on http://{host}:{port} (docs: http://{host}:{port}/docs)") - uvicorn.run(create_app(), host=host, port=port, log_level="warning") + uvicorn.run(application, host=host, port=port, log_level="warning", **options) @app.command() diff --git a/src/chatlore/web/app.js b/src/chatlore/web/app.js index 35d639e..df323d2 100644 --- a/src/chatlore/web/app.js +++ b/src/chatlore/web/app.js @@ -128,6 +128,15 @@ api("/stats") }) .catch(() => {}); +// A public server, such as the hosted demo, says so. +api("/health") + .then((health) => { + if (!health.public) return; + $("#ask-hero p").textContent = "A demo on made-up conversations. Every answer cites the chats it came from."; + $("#demo-note").hidden = false; + }) + .catch(() => {}); + // -- conversations --------------------------------------------------------------------- async function openConversation(id, messageId) { diff --git a/src/chatlore/web/index.html b/src/chatlore/web/index.html index 5c2ef70..cb81526 100644 --- a/src/chatlore/web/index.html +++ b/src/chatlore/web/index.html @@ -52,6 +52,10 @@
Entities
–
Topics
–
+ diff --git a/src/chatlore/web/style.css b/src/chatlore/web/style.css index a43479d..7550c7a 100644 --- a/src/chatlore/web/style.css +++ b/src/chatlore/web/style.css @@ -139,6 +139,9 @@ nav button.active { background: var(--accent-soft); color: var(--accent-text); } .library dl div { display: flex; justify-content: space-between; font-size: 13px; } .library dt { color: var(--muted); } .library dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; } +.demo-note { margin: 10px 0 0; padding-top: 10px; border-top: 1px solid var(--border); font-size: 12px; line-height: 1.45; color: var(--muted); } +.demo-note code { font-size: 11.5px; color: var(--text-2); } +.demo-note a { color: var(--accent-text); } main { overflow-y: auto; position: relative; } diff --git a/tests/test_public.py b/tests/test_public.py new file mode 100644 index 0000000..71b1cac --- /dev/null +++ b/tests/test_public.py @@ -0,0 +1,167 @@ +"""Tests for serving a public demo: question limits, the demo flag, and MCP over HTTP.""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from typer.testing import CliRunner + +from chatlore.api import _Allowance, create_app +from chatlore.chat import ChatLimits +from chatlore.cli import app as cli +from tests.fakes import FakeEmbedder, FakeLLM + +runner = CliRunner() + +NOTES = { + "Mac tools": "Parsec keeps dropping frames on the Mac. Try Moonlight with Sunshine instead.", + "Weekend": "Lisbon and Sintra make a good weekend. Take the train from Rossio to Sintra.", +} +MCP_HEADERS = {"Accept": "application/json, text/event-stream"} +LIST_TOOLS = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"} + + +@pytest.fixture +def home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + target = tmp_path / "home" + monkeypatch.setenv("CHATLORE_HOME", str(target)) + for module in ("cli", "api", "mcp_server"): + monkeypatch.setattr(f"chatlore.{module}.make_embedder", lambda home: FakeEmbedder()) + fake = FakeLLM() + monkeypatch.setattr("chatlore.cli.make_llm", lambda: fake) + monkeypatch.setattr("chatlore.api.make_llm", lambda: fake) + for title, text in NOTES.items(): + runner.invoke(cli, ["note", text, "--title", title]) + runner.invoke(cli, ["process"]) + runner.invoke(cli, ["extract"]) + return target + + +@pytest.fixture +def public(home: Path) -> Iterator[TestClient]: + limits = ChatLimits(per_visitor_hour=2, per_day=3) + with TestClient(create_app(home, public=True, limits=limits)) as client: + yield client + + +def ask(client: TestClient) -> list[str]: + """Ask a question and return the kinds of events streamed back.""" + response = client.post("/chat", json={"question": "Which train goes to Sintra?"}) + return [ + line.split(": ", 1)[1] for line in response.text.splitlines() if line.startswith("event") + ] + + +def test_a_visitor_gets_a_few_questions_an_hour() -> None: + now = [1_000_000.0] + allowance = _Allowance(ChatLimits(per_visitor_hour=2, per_day=100), clock=lambda: now[0]) + + first, second, third = (allowance.take("a") for _ in range(3)) + other = allowance.take("b") + now[0] += 3600 + later = allowance.take("a") + + assert first is None and second is None + assert third is not None and "2 questions an hour" in third + assert other is None + assert later is None + + +def test_all_visitors_share_a_daily_limit_that_resets_at_midnight_utc() -> None: + now = [86_400.0 * 20_000 + 3600] + allowance = _Allowance(ChatLimits(per_visitor_hour=10, per_day=2), clock=lambda: now[0]) + + answers = [allowance.take(visitor) for visitor in ("a", "b", "c")] + now[0] += 86_400 + tomorrow = allowance.take("c") + + assert answers[:2] == [None, None] + assert answers[2] is not None and "all the questions it can today" in answers[2] + assert tomorrow is None + + +def test_a_public_server_limits_questions_and_says_why(public: TestClient) -> None: + events = [ask(public) for _ in range(3)] + + assert events[0][-1] == "done" + assert events[1][-1] == "done" + assert events[2] == ["sources", "error"] + refused = public.post("/chat", json={"question": "Which train goes to Sintra?"}).text + assert "2 questions an hour for each visitor" in refused + assert "pip install chatlore" in refused + + +def test_questions_that_find_nothing_are_not_counted(tmp_path: Path) -> None: + limits = ChatLimits(per_visitor_hour=1, per_day=1) + with TestClient(create_app(tmp_path / "empty", public=True, limits=limits)) as client: + for _ in range(3): + assert ask(client) == ["sources", "done"] + + +def test_the_health_check_says_whether_the_server_is_public(public: TestClient, home: Path) -> None: + with TestClient(create_app(home)) as private: + assert private.get("/health").json()["public"] is False + assert public.get("/health").json()["public"] is True + + +def test_a_private_server_answers_questions_without_limits(home: Path) -> None: + with TestClient(create_app(home)) as private: + for _ in range(5): + response = private.post("/chat", json={"question": "Which train goes to Sintra?"}) + assert "event: done" in response.text + + +def test_mcp_is_served_over_http(public: TestClient) -> None: + listed = public.post("/mcp", json=LIST_TOOLS, headers=MCP_HEADERS).json() + called = public.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "search", "arguments": {"query": "Sintra"}}, + }, + headers=MCP_HEADERS, + ).json() + + assert [tool["name"] for tool in listed["result"]["tools"]][:2] == ["search", "ask_context"] + assert "Weekend" in called["result"]["content"][0]["text"] + + +def test_a_private_server_only_answers_mcp_requests_for_this_machine(home: Path) -> None: + with TestClient(create_app(home)) as private: + foreign = private.post( + "/mcp", json=LIST_TOOLS, headers={**MCP_HEADERS, "Host": "evil.example"} + ) + local = private.post( + "/mcp", json=LIST_TOOLS, headers={**MCP_HEADERS, "Host": "127.0.0.1:8000"} + ) + + assert foreign.status_code == 421 + assert local.status_code == 200 + assert json.loads(local.text)["result"]["tools"] + + +def test_serve_public_trusts_the_proxy_and_sets_the_limits( + home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + started: list[tuple[Any, dict[str, Any]]] = [] + monkeypatch.setattr("uvicorn.run", lambda app, **options: started.append((app, options))) + + result = runner.invoke( + cli, ["serve", "--public", "--host", "0.0.0.0", "--questions-per-hour", "4"] + ) + + app, options = started[0] + assert result.exit_code == 0 + assert options["proxy_headers"] is True + assert options["forwarded_allow_ips"] == "*" + assert "ask 4 questions an hour each, 300 a day in all" in " ".join(result.output.split()) + assert "Listening beyond this machine" not in result.output + with TestClient(app) as client: + assert client.get("/health").json()["public"] is True