From 9669d59e8f4dae2044111a12b0dddb76940ef66d Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 23:10:30 +0200 Subject: [PATCH 01/65] fix: make the self-hosting recipe actually work Following the README recipe verbatim on a fresh clone leaves the stack half-up: the web container exits 1 during bootstrap with DJANGO_SECRET_KEY must be set to a strong unique value. CommandError: Refusing to bootstrap with unsafe configuration. validate_startup_environment() blocks on three values, but the recipe only told you to change two. Postgres, Hatchet and the worker all report healthy, so nothing looks wrong until you go read the web logs. Name DJANGO_SECRET_KEY in the recipe, say what the check enforces, and flag it in .env.example next to the value with a command to generate one. Also give web the restart policy the worker already has. Its whole command is idempotent -- migrate, bootstrap and seed each skip what already exists -- so a restart is safe, and without one a gunicorn crash or a Postgres blip leaves the UI down permanently. A bad .env now fails repeatedly in the logs rather than once into a container nobody thinks to look at. --- .env.example | 2 ++ README.md | 3 ++- docker-compose.yml | 6 ++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 2c1ab31c..a8d65056 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,8 @@ # ============================================================================= # --- Django ----------------------------------------------------------------- +# REQUIRED: startup refuses to boot while this is empty or still `change-me`. +# Generate one with: openssl rand -hex 32 DJANGO_SECRET_KEY=change-me DJANGO_DEBUG=false DJANGO_ALLOWED_HOSTS=* diff --git a/README.md b/README.md index e959e6ac..0b73d7bd 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,8 @@ For teams or multi-user setups, use Docker Compose: git clone https://github.com/SushantGautam/SimpleAuditStudio cd SimpleAuditStudio cp .env.example .env -# edit POSTGRES_PASSWORD and BOOTSTRAP_PASSWORD at minimum +# edit DJANGO_SECRET_KEY, POSTGRES_PASSWORD and BOOTSTRAP_PASSWORD at minimum — +# startup refuses to boot while any of them is empty or still `change-me` docker compose up -d ``` diff --git a/docker-compose.yml b/docker-compose.yml index 5a2157f3..1311e6ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -160,6 +160,12 @@ services: interval: 30s timeout: 5s retries: 5 + # Matches the worker. The whole command is idempotent (migrate, bootstrap + # and seed all skip what already exists), so a restart is safe and a + # gunicorn crash or a Postgres blip no longer leaves the UI down for good. + # A bad .env still fails every attempt, but the error repeats in the logs + # instead of being printed once into a container nobody looks at. + restart: unless-stopped volumes: postgres_data: From d06b942e90c1c06dda39c64e6ed90dfb576e0642 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Wed, 30 Sep 2026 00:31:42 +0200 Subject: [PATCH 02/65] fix: monitor tick never runs on PostgreSQL (#5) run_due_monitors claimed due monitors with select_for_update(skip_locked=True) while select_related pulled in last_run and created_by. Both are nullable, so the ORM joins them with a LEFT OUTER JOIN, and PostgreSQL rejects FOR UPDATE against the nullable side of one: FOR UPDATE cannot be applied to the nullable side of an outer join Every tick therefore raised NotSupportedError on any PostgreSQL deployment, so no monitor ever launched a run. The sweeper catches and logs the failure, so the only symptom was a warning once a minute: Monitor tick failed: FOR UPDATE cannot be applied to the nullable side of an outer join Observed on a Container Apps deployment against Azure PostgreSQL 16. Lock only the monitor rows with of=("self",), which keeps select_related and the skip_locked guarantee that two workers never claim the same tick. The test suite runs on SQLite, which drops FOR UPDATE entirely and so cannot reproduce this. The claim query moves into due_monitors() so the lock target can be asserted on any backend, and a PostgreSQL-only execution test covers the regression itself. CI is SQLite-only, so that test is skipped there; running the suite against PostgreSQL reproduces the failure without this fix. --- audits/monitors.py | 28 +++++++++++++++++++++------ infra/tests/test_monitors.py | 37 +++++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/audits/monitors.py b/audits/monitors.py index 669f3fce..d31f9337 100644 --- a/audits/monitors.py +++ b/audits/monitors.py @@ -348,6 +348,27 @@ def create_monitor(*, project, user, name: str, run: dict, repeat: dict, experim return monitor +def due_monitors(now): + """Claim every enabled monitor that is due, locking only the monitor rows. + + ``of=("self",)`` is required, not a refinement. ``last_run`` and ``created_by`` + are both nullable, so ``select_related`` joins them with a LEFT OUTER JOIN, and + PostgreSQL rejects ``FOR UPDATE`` against the nullable side of an outer join: + + FOR UPDATE cannot be applied to the nullable side of an outer join + + Without ``of``, every tick raises that on PostgreSQL -- so no monitor ever runs + on a production database, while the sweeper logs the failure and carries on. + SQLite omits ``FOR UPDATE`` entirely, which is why the test suite stayed green. + """ + return ( + Monitor.objects.select_for_update(of=("self",), skip_locked=True) + .filter(enabled=True, next_run_at__lte=now) + .select_related("last_run", "project", "created_by") + .order_by("next_run_at") + ) + + def run_due_monitors(now=None) -> list[int]: """Launch every enabled monitor whose ``next_run_at`` has passed. @@ -362,12 +383,7 @@ def run_due_monitors(now=None) -> list[int]: now = now or timezone.now() created: list[AuditRun] = [] with transaction.atomic(): - due = ( - Monitor.objects.select_for_update(skip_locked=True) - .filter(enabled=True, next_run_at__lte=now) - .select_related("last_run", "project", "created_by") - .order_by("next_run_at") - ) + due = due_monitors(now) for monitor in due: monitor.next_run_at = next_after(monitor, now) monitor.last_tick_at = now diff --git a/infra/tests/test_monitors.py b/infra/tests/test_monitors.py index 801fb31a..6e041698 100644 --- a/infra/tests/test_monitors.py +++ b/infra/tests/test_monitors.py @@ -4,8 +4,9 @@ SIMPLEAUDIT_LOCAL_SQLITE=1 uv run manage.py test infra.tests.test_monitors """ from datetime import UTC, timedelta -from unittest import mock +from unittest import mock, skipUnless +from django.db import connection from django.test import Client, TestCase from django.utils import timezone @@ -13,6 +14,7 @@ from audits.monitors import ( advance, drift_series, + due_monitors, run_due_monitors, two_proportion_z, wilson, @@ -524,3 +526,36 @@ def test_experiment_repeat_creates_linked_monitors(self): self.assertEqual(len(page.context["current_runs"]), 2) pooled = self.client.get(f"/experiments/{exp.id}/?pool=1") self.assertTrue(pooled.context["pool"]) + + +class DueMonitorQueryTests(MonitorTestBase): + """The claim query must lock only the monitor rows. + + ``select_related`` pulls in ``last_run`` and ``created_by``, both nullable, so + the join is a LEFT OUTER JOIN. PostgreSQL rejects ``FOR UPDATE`` against the + nullable side of one ("FOR UPDATE cannot be applied to the nullable side of an + outer join"), which made every tick fail on a production database while the + sweeper logged the error and carried on. + + SQLite drops ``FOR UPDATE`` altogether, so the failure cannot be reproduced on + the suite's default backend. These assertions are on the query the ORM builds, + which is backend-independent; the execution test below runs on PostgreSQL only. + """ + + def test_claim_query_locks_only_monitor_rows(self): + query = due_monitors(timezone.now()).query + self.assertEqual(query.select_for_update_of, ("self",)) + self.assertTrue(query.select_for_update_skip_locked) + + def test_claim_query_still_joins_the_nullable_relations(self): + # If these stop being selected the `of` above is no longer load-bearing, + # and a future change could drop it without any test noticing. + self.assertEqual( + set(due_monitors(timezone.now()).query.select_related), + {"last_run", "project", "created_by"}, + ) + + @skipUnless(connection.vendor == "postgresql", "outer-join locking is PostgreSQL-specific") + def test_tick_executes_on_postgresql(self): + # The regression itself: this raises NotSupportedError without `of`. + run_due_monitors() From f49fa7ab966bffa0ba6f7b88d7bdf0e1badbda41 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Wed, 30 Sep 2026 00:32:08 +0200 Subject: [PATCH 03/65] chore: bump version to 0.5.3 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d168c2ba..d6e68a72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "simpleaudit-studio" -version = "0.5.2" +version = "0.5.3" description = "Production AI model audit platform — one-liner demo mode via uvx" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 872769bb..e2c8f53f 100644 --- a/uv.lock +++ b/uv.lock @@ -2163,7 +2163,7 @@ wheels = [ [[package]] name = "simpleaudit-studio" -version = "0.5.2" +version = "0.5.3" source = { editable = "." } dependencies = [ { name = "cronsim" }, From 581f42059bbaf180c5ddc6bfdb1b22d7c3c2314f Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Wed, 30 Sep 2026 09:46:07 +0200 Subject: [PATCH 04/65] fix: every new user lands in the Default workspace User-creation paths (admin add-user, self-registration, demo signup) made accounts with no project membership, so request.project resolved to None and every project-scoped view 500'd. Centralize the grant in accounts.services.grant_default_project (viewer on the 'default' slug) and call it from all four creation paths, matching the existing WorkOS behavior. Add a middleware backstop so a membership-less user still resolves the Default workspace instead of crashing. --- accounts/serializers.py | 6 +- accounts/services.py | 21 +++- infra/middleware.py | 9 +- infra/tests/test_user_project_assignment.py | 112 ++++++++++++++++++++ infra/ui.py | 15 ++- 5 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 infra/tests/test_user_project_assignment.py diff --git a/accounts/serializers.py b/accounts/serializers.py index 398905bc..503e9f55 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -33,7 +33,11 @@ def validate_password(self, value): return value def create(self, validated_data): - return User.objects.create_user(**validated_data) + user = User.objects.create_user(**validated_data) + from accounts.services import grant_default_project + + grant_default_project(user) + return user class WorkspaceItemSerializer(serializers.ModelSerializer): diff --git a/accounts/services.py b/accounts/services.py index 03e33dc9..3579e049 100644 --- a/accounts/services.py +++ b/accounts/services.py @@ -79,6 +79,23 @@ def bootstrap_admin_and_default_project( DEFAULT_PROJECT_SLUG = "default" +def grant_default_project(user) -> Project | None: + """Give a new user a viewer membership in the shared 'default' workspace. + + Every user-creation path (admin add-user, self-registration, WorkOS magic + auth, demo signup) calls this so a fresh account always has a project to + land in — otherwise ``request.project`` resolves to ``None`` and the UI + 500s. Idempotent: an existing membership is left untouched. Returns the + default project, or ``None`` when it does not exist. + """ + project = Project.objects.filter(slug=DEFAULT_PROJECT_SLUG).first() + if project: + ProjectMembership.objects.get_or_create( + project=project, user=user, defaults={"role": ProjectMembership.Role.VIEWER} + ) + return project + + def ensure_project_access(user, project) -> bool: """Return True if the user may view this project's content. @@ -263,13 +280,15 @@ def admin_create_user(*, admin_user, username: str, email: str = "", password: s from django.contrib.auth.password_validation import validate_password validate_password(password) - return User.objects.create_user( + user = User.objects.create_user( username=clean_username, email=clean_email, password=password, first_name=(first_name or "").strip(), last_name=(last_name or "").strip(), ) + grant_default_project(user) + return user @transaction.atomic diff --git a/infra/middleware.py b/infra/middleware.py index 863f87a2..e6699eec 100644 --- a/infra/middleware.py +++ b/infra/middleware.py @@ -110,4 +110,11 @@ def process_request(self, request): membership = request.user.memberships.select_related("project").first() if membership: request.project = membership.project - request.session["active_project_id"] = request.project.id \ No newline at end of file + request.session["active_project_id"] = request.project.id + if not request.project: + # Backstop: a user with no membership (legacy/orphaned account) + # still lands in the shared Default workspace rather than crashing + # every view that dereferences request.project. + from accounts.services import DEFAULT_PROJECT_SLUG + + request.project = Project.objects.filter(slug=DEFAULT_PROJECT_SLUG).first() \ No newline at end of file diff --git a/infra/tests/test_user_project_assignment.py b/infra/tests/test_user_project_assignment.py new file mode 100644 index 00000000..b9fa30e2 --- /dev/null +++ b/infra/tests/test_user_project_assignment.py @@ -0,0 +1,112 @@ +"""Every user-creation path must land the new user in the 'default' workspace. + +A user with no project membership makes ``request.project`` resolve to ``None`` +and 500s every project-scoped view. These tests pin the invariant that each +door into the system (admin add-user, self-registration, demo signup) grants a +viewer membership in the shared Default workspace, and that the middleware +backstop keeps even a membership-less user from crashing. +""" +from django.test import Client, TestCase + +from accounts.models import Project, ProjectMembership, User +from infra.tests.factories import ProjectFactory +from infra.tests.utils import login, post_json, superuser + + +def _default_project(): + return Project.objects.create(name="Default", slug="default") + + +class AdminCreateUserAssignmentTest(TestCase): + def setUp(self): + self.client = Client(SERVER_NAME="localhost") + self.admin = superuser() + login(self.client, self.admin) + + def test_admin_added_user_gets_default_viewer(self): + _default_project() + resp = post_json( + self.client, + "/api/admin/users/", + {"username": "newbie", "email": "newbie@test.com", "password": "Str0ng-pass-123"}, + ) + self.assertEqual(resp.status_code, 201) + user = User.objects.get(username="newbie") + default = Project.objects.get(slug="default") + self.assertTrue( + ProjectMembership.objects.filter( + project=default, user=user, role=ProjectMembership.Role.VIEWER + ).exists() + ) + + def test_admin_added_user_without_default_project_is_safe(self): + # No 'default' project exists (e.g. pre-bootstrap): user is created, + # just without a membership — no crash. + resp = post_json( + self.client, + "/api/admin/users/", + {"username": "orphan", "password": "Str0ng-pass-123"}, + ) + self.assertEqual(resp.status_code, 201) + self.assertEqual(ProjectMembership.objects.filter(user__username="orphan").count(), 0) + + +class RegisterApiAssignmentTest(TestCase): + def test_register_grants_default_viewer(self): + _default_project() + resp = self.client.post( + "/api/auth/register/", + {"username": "carol", "email": "carol@example.com", "password": "another-strong-pass"}, + ) + self.assertEqual(resp.status_code, 201) + user = User.objects.get(username="carol") + default = Project.objects.get(slug="default") + self.assertTrue( + ProjectMembership.objects.filter( + project=default, user=user, role=ProjectMembership.Role.VIEWER + ).exists() + ) + + +class DemoSignupAssignmentTest(TestCase): + def test_demo_register_grants_default_viewer(self): + _default_project() + resp = self.client.post( + "/register/", + {"username": "demo-user", "password": "Str0ng-pass-123", "email": "demo@test.com"}, + ) + self.assertEqual(resp.status_code, 302) + user = User.objects.get(username="demo-user") + default = Project.objects.get(slug="default") + self.assertTrue( + ProjectMembership.objects.filter( + project=default, user=user, role=ProjectMembership.Role.VIEWER + ).exists() + ) + + +class ProjectMiddlewareBackstopTest(TestCase): + """A user with no membership must still resolve a project (no 500).""" + + def test_membershipless_user_falls_back_to_default(self): + default = _default_project() + # A non-default project exists too, to prove the fallback is by slug. + ProjectFactory(name="Other", slug="other") + user = User.objects.create_user(username="orphan", password="orphan-pass-1") + + client = Client(SERVER_NAME="localhost") + client.force_login(user) + resp = client.get("/") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.wsgi_request.project, default) + + def test_membershipless_user_without_default_project_gets_none(self): + # No 'default' project at all: request.project stays None (the views + # must tolerate that), but the request itself must not 500. + ProjectFactory(name="Other", slug="other") + user = User.objects.create_user(username="orphan2", password="orphan-pass-2") + + client = Client(SERVER_NAME="localhost") + client.force_login(user) + resp = client.get("/healthz") + self.assertEqual(resp.status_code, 200) diff --git a/infra/ui.py b/infra/ui.py index c597b398..df1970b7 100644 --- a/infra/ui.py +++ b/infra/ui.py @@ -171,6 +171,9 @@ def post(self, request, *args, **kwargs): error = "Username already taken." else: user = User.objects.create_user(username=username, password=password, email=email) + from accounts.services import grant_default_project + + grant_default_project(user) login(request, user) return redirect("dashboard") return self.render_to_response(self.get_context_data(error=error)) @@ -286,16 +289,12 @@ def post(self, request): def _grant_default_project(user): """Give first-time WorkOS users membership in the 'Default' project (viewer). - The 'Default' workspace is reserved for this purpose — it is created during - platform bootstrap and serves as the shared landing space for new users. + Thin wrapper over the shared ``grant_default_project`` service so every + user-creation path lands new users in the same shared landing space. """ - from accounts.models import Project, ProjectMembership + from accounts.services import grant_default_project - project = Project.objects.filter(slug="default").first() - if project: - ProjectMembership.objects.get_or_create( - project=project, user=user, defaults={"role": ProjectMembership.Role.VIEWER} - ) + grant_default_project(user) # ─── Dashboard ─────────────────────────────────────────────────────────────── From dc2c9f5b6149b69ba6373cc6e7b2f205bde9d403 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 16:50:32 +0200 Subject: [PATCH 05/65] feat: optional Open WebUI chat module with Studio single sign-on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an off-by-default module that embeds Open WebUI in Studio at /chat/, signed in as the Studio user. With SIMPLEAUDIT_CHAT unset nothing changes: the routes 404, the sidebar has no Chat entry and no extra process runs. Open WebUI serves from the root of an origin only — it has no base-path setting and its HTML references /static, /api and /ws absolutely — so it cannot be proxied under Studio's own /chat/ path. It gets its own origin, which Studio embeds in an iframe. Sign-on uses Open WebUI's trusted-header mode. A proxy in front of it asks Studio who the browser is (GET /chat/authz, the standard forward-auth contract) and injects the answer as X-Studio-Email/-Name/-Role. Django stays the only authority on identity; nothing outside it reads sessions or user tables. Superusers and workspace admins map to Open WebUI's admin role, everyone else to user. Two deployments, same contract: embedded infra/chat_proxy.py, a stdlib HTTP proxy started by `uvx simpleaudit-studio --chat` alongside Open WebUI on loopback docker `docker compose --profile chat up`, where Caddy does forward_auth and Open WebUI publishes no port Open WebUI must be reachable only from the proxy: it believes the identity headers on any request it receives. Both proxies strip client-supplied X-Studio-* headers before adding their own. Note that `open-webui serve` ignores HOST/PORT and defaults to 0.0.0.0, so the bind address is passed as CLI flags; it also writes its signing key to the working directory, so the process runs from its own data folder. WebSockets are not proxied in embedded mode; Socket.IO falls back to HTTP long-polling and SSE streaming is unaffected. --- README.md | 22 ++++- config/urls.py | 10 ++ deploy/compose/Caddyfile.chat | 34 +++++++ docker-compose.yml | 39 ++++++++ docs/chat.md | 110 ++++++++++++++++++++++ infra/chat.py | 109 ++++++++++++++++++++++ infra/chat_proxy.py | 168 ++++++++++++++++++++++++++++++++++ infra/context_processors.py | 4 +- infra/tests/test_chat.py | 80 ++++++++++++++++ simpleaudit_studio/cli.py | 25 +++++ templates/chat.html | 13 +++ 11 files changed, 612 insertions(+), 2 deletions(-) create mode 100644 deploy/compose/Caddyfile.chat create mode 100644 docs/chat.md create mode 100644 infra/chat.py create mode 100644 infra/chat_proxy.py create mode 100644 infra/tests/test_chat.py create mode 100644 templates/chat.html diff --git a/README.md b/README.md index 0b73d7bd..c50b8a55 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,30 @@ cp .env.example .env docker compose up -d ``` -Services: Web UI (:8000), PostgreSQL, Hatchet queue (:8888), Worker. Optional profile: `--profile mock` (mock model API). +Services: Web UI (:8000), PostgreSQL, Hatchet queue (:8888), Worker. Optional profiles: `--profile mock` (mock model API), `--profile chat` (Open WebUI). See [docs/deployment.md](docs/deployment.md) for production hardening, backups, and upgrades. +## 💬 Chat (optional) + +SimpleAudit Studio can embed [Open WebUI](https://openwebui.com) under `/chat/`, +signed in as your Studio user — workspace admins become Open WebUI admins. It is +off unless you turn it on, and nothing else changes when you do. + +```bash +uvx simpleaudit-studio --chat # local: starts Open WebUI alongside Studio +``` + +```bash +# Docker: add to .env, then +# SIMPLEAUDIT_CHAT=docker +# SIMPLEAUDIT_CHAT_URL=http://localhost:8801 +docker compose --profile chat up -d +``` + +See [docs/chat.md](docs/chat.md) for how single sign-on works and what must stay +private. + ## ✨ What You Can Do - Build versioned scenario sets and register OpenAI-compatible models diff --git a/config/urls.py b/config/urls.py index 74c0a185..3e4884b1 100644 --- a/config/urls.py +++ b/config/urls.py @@ -10,6 +10,7 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from accounts.views import healthz, readyz +from infra import chat as _chat from infra.health_api import health_panel_api from infra.runs_table import PreferenceView, RunsBulkView, RunsDataView, RunsExportView from infra.seo import LandingView, llms_txt, robots_txt, sitemap_xml @@ -202,3 +203,12 @@ def home_view(request, *args, **kwargs): path("runs/export.csv", RunsExportView.as_view(), name="runs_export"), path("me/preferences/", PreferenceView.as_view(), name="preferences"), ] + +# Optional Open WebUI module. The chat UI itself lives on its own origin; these +# are the iframe page and the forward-auth endpoint its proxy calls. Both 404 +# unless SIMPLEAUDIT_CHAT is set. See infra/chat.py. +urlpatterns += [ + path("chat/", _chat.ChatView.as_view(), name="chat"), + path("chat/authz", _chat.authz, name="chat_authz"), +] + diff --git a/deploy/compose/Caddyfile.chat b/deploy/compose/Caddyfile.chat new file mode 100644 index 00000000..44a9819a --- /dev/null +++ b/deploy/compose/Caddyfile.chat @@ -0,0 +1,34 @@ +# Forward-auth proxy in front of Open WebUI (docker mode of the optional chat +# module; see infra/chat.py). Caddy asks Studio who the browser is and injects +# the answer as the trusted headers Open WebUI reads. +# +# Open WebUI has no published port in docker-compose.yml, so this proxy is the +# only way to reach it. Keep it that way: a client that can talk to it directly +# can set X-Studio-Role: admin and take over the instance. +{ + auto_https off + admin off +} + +:{$SIMPLEAUDIT_CHAT_PROXY_PORT:8801} { + # Never let a client supply its own identity. + request_header -X-Studio-Email + request_header -X-Studio-Name + request_header -X-Studio-Role + + forward_auth {$STUDIO_UPSTREAM:web:8000} { + uri /chat/authz + copy_headers X-Studio-Email X-Studio-Name X-Studio-Role + + # Signed out: send the browser to Studio rather than showing a bare 401. + @signedout status 401 + handle_response @signedout { + redir {$STUDIO_URL:http://localhost:8000}/chat/ 302 + } + } + + # Studio embeds this origin in an iframe. + header -X-Frame-Options + + reverse_proxy open-webui:{$SIMPLEAUDIT_CHAT_UPSTREAM_PORT:8080} +} diff --git a/docker-compose.yml b/docker-compose.yml index 1311e6ea..a72b0b0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -167,7 +167,46 @@ services: # instead of being printed once into a container nobody looks at. restart: unless-stopped + # Optional chat module: `docker compose --profile chat up`, with + # SIMPLEAUDIT_CHAT=docker and SIMPLEAUDIT_CHAT_URL=http://localhost:8801 in + # .env so the web service serves /chat/. See infra/chat.py. + # + # Open WebUI deliberately publishes NO port: chat-proxy is the only route to + # it, because it trusts the identity headers on any request it receives. + open-webui: + profiles: ["chat"] + image: ${OPEN_WEBUI_IMAGE:-ghcr.io/open-webui/open-webui:main} + environment: + WEBUI_AUTH_TRUSTED_EMAIL_HEADER: X-Studio-Email + WEBUI_AUTH_TRUSTED_NAME_HEADER: X-Studio-Name + WEBUI_AUTH_TRUSTED_ROLE_HEADER: X-Studio-Role + ENABLE_SIGNUP: "false" + WEBUI_URL: ${SIMPLEAUDIT_CHAT_URL:-http://localhost:8801} + # Open WebUI's own CLI flags: it ignores HOST/PORT. + command: open-webui serve --host 0.0.0.0 --port ${SIMPLEAUDIT_CHAT_UPSTREAM_PORT:-8080} + volumes: + - open_webui_data:/app/backend/data + restart: unless-stopped + + chat-proxy: + profiles: ["chat"] + image: ${CADDY_IMAGE:-caddy:2-alpine} + environment: + STUDIO_URL: ${SIMPLEAUDIT_STUDIO_URL:-http://localhost:8000} + SIMPLEAUDIT_CHAT_PROXY_PORT: ${SIMPLEAUDIT_CHAT_PROXY_PORT:-8801} + SIMPLEAUDIT_CHAT_UPSTREAM_PORT: ${SIMPLEAUDIT_CHAT_UPSTREAM_PORT:-8080} + STUDIO_UPSTREAM: web:8000 + volumes: + - ./deploy/compose/Caddyfile.chat:/etc/caddy/Caddyfile:ro + ports: + - "${SIMPLEAUDIT_CHAT_PROXY_PORT:-8801}:${SIMPLEAUDIT_CHAT_PROXY_PORT:-8801}" + depends_on: + - open-webui + - web + restart: unless-stopped + volumes: postgres_data: + open_webui_data: # Shared Hatchet config; carries the auth-disabled worker JWT from server -> worker. hatchet_config: diff --git a/docs/chat.md b/docs/chat.md new file mode 100644 index 00000000..cd5de069 --- /dev/null +++ b/docs/chat.md @@ -0,0 +1,110 @@ +# Chat (Open WebUI) + +An optional module that embeds [Open WebUI](https://openwebui.com) in Studio at +`/chat/`, signed in as the Studio user. It is disabled by default: with +`SIMPLEAUDIT_CHAT` unset, `/chat/` and `/chat/authz` return 404, the sidebar has +no Chat entry, and nothing extra runs. + +## Why it is an iframe, not a sub-path + +Open WebUI serves from the root of an origin only. It has no base-path setting, +and its HTML references `/static`, `/api` and `/ws` absolutely, so proxying it +under `https://studio/chat/` serves a broken page. It therefore gets its own +origin (a port locally, a host in production) which Studio embeds. + +## How sign-on works + +Open WebUI's *trusted header* mode: it accepts the identity of whoever calls it +in HTTP headers. A proxy in front of it decides that identity by asking Studio, +using the standard forward-auth contract. + +``` +browser ──► proxy (:8801) + │ strips any client-supplied X-Studio-* header + │ + ├──► Studio GET /chat/authz (browser cookies forwarded) + │ 401 -> proxy redirects the browser to Studio + │ 200 -> X-Studio-Email, X-Studio-Name, X-Studio-Role + │ + └──► Open WebUI (127.0.0.1:8080, no published port) +``` + +Django remains the only authority on identity; nothing outside it reads sessions +or user tables. Open WebUI creates its account on the first request per user and +keeps its own database of chats and settings. + +Role mapping (`X-Studio-Role`, applied on every sign-in): + +| Studio | Open WebUI | +|------------------------------------------|------------| +| superuser, or admin of any workspace | `admin` | +| everyone else | `user` | + +## Security + +**Open WebUI must be reachable only from the proxy.** It believes the headers on +any request it receives, so a client that can connect to it directly can send +`X-Studio-Role: admin` and take over the instance. Embedded mode binds it to +loopback; the compose profile publishes no port for it. Both the Python proxy and +the Caddy config strip client-supplied `X-Studio-*` headers before adding their +own — if you put your own proxy in front, it must do the same. + +## Local (no Docker) + +```bash +uvx simpleaudit-studio --chat +``` + +Starts Open WebUI (via `open-webui` if installed, otherwise `uvx`) on +127.0.0.1:8080, plus the forward-auth proxy from `infra/chat_proxy.py` on :8801. +Open WebUI's data lives beside Studio's, in `~/.simpleaudit-studio/openwebui/`, +and it runs from that folder so its signing key stays there too. The first start +downloads it (a few hundred MB), so `/chat/` stays blank for a minute or two. + +`open-webui serve` ignores `HOST`/`PORT` and defaults to **0.0.0.0**:8080, so +Studio passes `--host`/`--port` explicitly. If you override the command with +`SIMPLEAUDIT_CHAT_CMD`, pass those flags yourself — binding it to all interfaces +is what the warning above is about. + +WebSockets are not proxied; Open WebUI's Socket.IO client falls back to HTTP +long-polling. Chat responses stream over SSE and are unaffected. + +## Docker + +```bash +# .env +SIMPLEAUDIT_CHAT=docker +SIMPLEAUDIT_CHAT_URL=http://localhost:8801 # what the browser opens +SIMPLEAUDIT_STUDIO_URL=http://localhost:8000 # where signed-out users are sent + +docker compose --profile chat up -d +``` + +This runs `open-webui` (no published port) behind `chat-proxy`, a Caddy container +configured by [deploy/compose/Caddyfile.chat](../deploy/compose/Caddyfile.chat). +Studio itself only serves the iframe page and `/chat/authz`. + +On separate hostnames (`studio.example.com` / `chat.example.com`), set +`SESSION_COOKIE_DOMAIN=.example.com` so the proxy receives Studio's session +cookie. Different registrable domains will not work. + +## Settings + +| Variable | Default | Meaning | +|---------------------------------|--------------------------|--------------------------------------------| +| `SIMPLEAUDIT_CHAT` | `off` | `embedded`, `docker` or `off` | +| `SIMPLEAUDIT_CHAT_URL` | `http://localhost:8801` | the origin the iframe loads | +| `SIMPLEAUDIT_CHAT_UPSTREAM` | `http://127.0.0.1:8080` | where Open WebUI listens | +| `SIMPLEAUDIT_CHAT_PROXY_PORT` | `8801` | the proxy's port (both modes) | +| `SIMPLEAUDIT_CHAT_UPSTREAM_PORT`| `8080` | Open WebUI's port (docker mode) | +| `SIMPLEAUDIT_STUDIO_URL` | `http://localhost:8000` | where signed-out users are sent (docker) | +| `SIMPLEAUDIT_CHAT_CMD` | auto | command that starts Open WebUI | + +## Removing it + +Leave `SIMPLEAUDIT_CHAT` unset. To drop the code, delete `infra/chat.py`, +`infra/chat_proxy.py`, `infra/tests/test_chat.py`, `templates/chat.html`, +`deploy/compose/Caddyfile.chat`, the two `chat/` routes in `config/urls.py`, the +Chat entry in `infra/context_processors.py`, the `--chat` flag in +`simpleaudit_studio/cli.py` and the `chat` profile in `docker-compose.yml`. +Nothing else refers to it. diff --git a/infra/chat.py b/infra/chat.py new file mode 100644 index 00000000..cfa70274 --- /dev/null +++ b/infra/chat.py @@ -0,0 +1,109 @@ +"""Optional Open WebUI chat module. Disabled unless SIMPLEAUDIT_CHAT is set. + +Open WebUI serves from the root of an origin only — it has no base-path/sub-path +setting, and its HTML references ``/static``, ``/api`` and ``/ws`` absolutely. So +it cannot be reverse-proxied under ``/chat/`` on Studio's own origin. Instead it +runs on its own port and Studio embeds that origin in an iframe. + +Single sign-on uses Open WebUI's trusted-header mode: a proxy in front of it asks +Studio who the browser is (``GET /chat/authz`` — the standard forward-auth +contract that Caddy/Traefik/nginx implement) and injects the answer as headers. +Django stays the only authority on identity; nothing reads Django's session or +user tables from outside. + + browser ──► proxy ──► GET /chat/authz (cookies forwarded) + │ 401 -> send the browser to Studio's login + │ 200 -> X-Studio-Email / -Name / -Role + └──► Open WebUI on 127.0.0.1:8080 + +SECURITY: Open WebUI must be reachable only from that proxy. Anyone who can +connect to it directly can send ``X-Studio-Role: admin`` and take over the +instance. Bind it to loopback (embedded mode) or keep it on an internal compose +network with no published port (docker mode). + +Modes (``SIMPLEAUDIT_CHAT``): + off the default — the URLs 404 and nothing starts + embedded the CLI starts Open WebUI and the proxy in infra.chat_proxy + docker an external proxy (Caddy) does forward-auth; Studio only serves + the iframe page and /chat/authz +""" +from __future__ import annotations + +import os + +from django.http import Http404, HttpResponse +from django.views.generic import TemplateView + +# --- Configuration --------------------------------------------------------- +MODE = (os.environ.get("SIMPLEAUDIT_CHAT") or "off").strip().lower() +ENABLED = MODE in ("embedded", "docker") + +#: Where Open WebUI itself listens. Never exposed to browsers. +UPSTREAM = os.environ.get("SIMPLEAUDIT_CHAT_UPSTREAM", "http://127.0.0.1:8080").rstrip("/") +#: The port the bundled forward-auth proxy listens on (embedded mode). +PROXY_PORT = int(os.environ.get("SIMPLEAUDIT_CHAT_PROXY_PORT", "8801")) +#: What the iframe points at — the proxy's origin, as the browser sees it. +PUBLIC_URL = os.environ.get("SIMPLEAUDIT_CHAT_URL", f"http://localhost:{PROXY_PORT}").rstrip("/") + +EMAIL_HEADER = "X-Studio-Email" +NAME_HEADER = "X-Studio-Name" +ROLE_HEADER = "X-Studio-Role" +#: Every header the proxy injects — it must strip all of them off the incoming +#: request before adding its own, or a client could forge them. +TRUSTED_HEADERS = (EMAIL_HEADER, NAME_HEADER, ROLE_HEADER) + + +def identity(user) -> dict[str, str]: + """The trusted headers describing a signed-in Studio user. + + Role maps onto Open WebUI's three values: a Studio superuser, or an admin of + any workspace, is an Open WebUI admin; everyone else is a user. + """ + from accounts.models import ProjectMembership + + is_admin = user.is_superuser or ProjectMembership.objects.filter( + user=user, role=ProjectMembership.Role.ADMIN, + ).exists() + return { + # Open WebUI keys accounts by email, so a user without one still needs a + # stable, unique value. + EMAIL_HEADER: user.email or f"{user.username}@studio.local", + NAME_HEADER: user.get_full_name() or user.username, + ROLE_HEADER: "admin" if is_admin else "user", + } + + +def authz(request): + """Forward-auth endpoint: who is this browser? + + 200 with the trusted headers when signed in, 401 otherwise. The proxy copies + the headers onto the upstream request and turns a 401 into a redirect to + Studio's login page. + """ + if not ENABLED: + raise Http404 + user = request.user + if not user.is_authenticated: + return HttpResponse(status=401) + response = HttpResponse(status=200) + for header, value in identity(user).items(): + response[header] = value + return response + + +class ChatView(TemplateView): + """The Studio page that embeds Open WebUI.""" + + template_name = "chat.html" + + def get(self, request, *args, **kwargs): + if not ENABLED: + raise Http404 + if not request.user.is_authenticated: + from django.shortcuts import redirect + + return redirect(f"/login/?next={request.path}") + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + return super().get_context_data(chat_url=PUBLIC_URL, **kwargs) diff --git a/infra/chat_proxy.py b/infra/chat_proxy.py new file mode 100644 index 00000000..53cd0f9b --- /dev/null +++ b/infra/chat_proxy.py @@ -0,0 +1,168 @@ +"""The forward-auth proxy that fronts Open WebUI in embedded mode. + +Docker deployments use Caddy for this (see deploy/compose/Caddyfile.chat); this +module is the no-Docker equivalent, so ``uvx simpleaudit-studio --chat`` needs +nothing but Python. Both do the same three things: + + 1. strip any client-supplied trusted header (otherwise anyone could forge one), + 2. ask Studio ``GET /chat/authz`` who the browser is, forwarding its cookies, + 3. forward the request to Open WebUI with the returned headers added. + +WebSockets are not proxied: an ``Upgrade`` request gets 501, which makes Open +WebUI's Socket.IO client stay on its HTTP long-polling transport. Chat responses +stream over plain HTTP (SSE) and are unaffected. +""" +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +import httpx + +from infra import chat + +logger = logging.getLogger(__name__) + +# Connection-level headers that must not be forwarded (RFC 9110 §7.6.1). +_HOP_BY_HOP = frozenset({ + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", "host", "content-length", +}) +_STRIP_FROM_REQUEST = _HOP_BY_HOP | {h.lower() for h in chat.TRUSTED_HEADERS} +# Dropped from the response so the iframe in Studio is allowed to render it. +# X-Frame-Options has no origin allow-list, so it can only be removed. +_STRIP_FROM_RESPONSE = _HOP_BY_HOP | {"x-frame-options"} + + +class _Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + studio_url = "http://127.0.0.1:8000" # set by serve() + client: httpx.Client # set by serve() + + def log_message(self, fmt, *args): + logger.debug("chat-proxy %s", fmt % args) + + def _proxy(self): + if self.headers.get("Upgrade", "").lower() == "websocket": + self.send_error(501, "WebSocket not proxied") + return + + headers = {k: v for k, v in self.headers.items() if k.lower() not in _STRIP_FROM_REQUEST} + # The body is forwarded byte for byte, so the upstream may only use an + # encoding this client asked for. Without this httpx adds its own + # Accept-Encoding and the client gets gzip it cannot read. + if not any(k.lower() == "accept-encoding" for k in headers): + headers["Accept-Encoding"] = "identity" + cookie = self.headers.get("Cookie", "") + + identity = self._identify(cookie) + if identity is None: + self.send_response(302) + self.send_header("Location", f"{self.studio_url}/chat/") + self.send_header("Content-Length", "0") + self.end_headers() + return + headers.update(identity) + + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length else None + + try: + with self.client.stream( + self.command, chat.UPSTREAM + self.path, headers=headers, content=body, + ) as upstream: + self.send_response(upstream.status_code) + for key, value in upstream.headers.multi_items(): + if key.lower() not in _STRIP_FROM_RESPONSE: + self.send_header(key, value) + # Responses are streamed without a known length (SSE included), + # so the connection delimits the body. + self.send_header("Connection", "close") + self.close_connection = True + self.end_headers() + for chunk in upstream.iter_raw(): + self.wfile.write(chunk) + self.wfile.flush() + except httpx.HTTPError as exc: + logger.warning("chat upstream error: %s", exc) + self.send_error(502, "Chat backend unavailable") + except (BrokenPipeError, ConnectionResetError): + pass # the browser navigated away mid-stream + + def _identify(self, cookie: str) -> dict[str, str] | None: + """Ask Studio who this browser is. None when signed out.""" + try: + response = self.client.get( + f"{self.studio_url}/chat/authz", + headers={"Cookie": cookie} if cookie else {}, + ) + except httpx.HTTPError as exc: + logger.warning("chat authz unreachable: %s", exc) + return None + if response.status_code != 200: + return None + return {h: response.headers[h] for h in chat.TRUSTED_HEADERS if h in response.headers} + + do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = _proxy + + +def serve(studio_port: int) -> ThreadingHTTPServer: + """Start the proxy on chat.PROXY_PORT in a daemon thread.""" + _Handler.studio_url = f"http://127.0.0.1:{studio_port}" + _Handler.client = httpx.Client(timeout=httpx.Timeout(None, connect=10.0), follow_redirects=False) + server = ThreadingHTTPServer(("0.0.0.0", chat.PROXY_PORT), _Handler) + server.daemon_threads = True + threading.Thread(target=server.serve_forever, daemon=True).start() + return server + + +def start_open_webui() -> subprocess.Popen: + """Start Open WebUI bound to loopback, in trusted-header mode. + + Uses the ``open-webui`` command when it is installed, otherwise ``uvx`` + fetches it on first run. SIMPLEAUDIT_CHAT_CMD overrides both. + """ + from simpleaudit_studio.paths import data_dir + + home = data_dir() / "openwebui" + home.mkdir(parents=True, exist_ok=True) + host = urlsplit(chat.UPSTREAM).hostname or "127.0.0.1" + port = urlsplit(chat.UPSTREAM).port or 8080 + + command = os.environ.get("SIMPLEAUDIT_CHAT_CMD") + if command: + argv = command.split() + elif shutil.which("open-webui"): + argv = ["open-webui", "serve"] + elif shutil.which("uvx"): + argv = ["uvx", "open-webui", "serve"] + else: + raise RuntimeError( + "Open WebUI not found. Install it (`uv tool install open-webui`) or set " + "SIMPLEAUDIT_CHAT_CMD to the command that starts it." + ) + if not command: + # `open-webui serve` ignores HOST/PORT and defaults to 0.0.0.0:8080. The + # bind address is the whole security model here — anything that can reach + # it can claim any identity — so it must come from the flags. + argv += ["--host", host, "--port", str(port)] + + env = { + **os.environ, + "DATA_DIR": str(home), + "WEBUI_AUTH_TRUSTED_EMAIL_HEADER": chat.EMAIL_HEADER, + "WEBUI_AUTH_TRUSTED_NAME_HEADER": chat.NAME_HEADER, + "WEBUI_AUTH_TRUSTED_ROLE_HEADER": chat.ROLE_HEADER, + "ENABLE_SIGNUP": "false", + "WEBUI_URL": chat.PUBLIC_URL, + } + # Open WebUI keeps its signing key in ``.webui_secret_key`` in the working + # directory, with no setting for it: running it from its own data folder + # keeps that out of wherever Studio was started and stable across restarts + # (a new key signs every session out). + return subprocess.Popen(argv, env=env, cwd=str(home)) diff --git a/infra/context_processors.py b/infra/context_processors.py index f5a0817d..b186bf88 100644 --- a/infra/context_processors.py +++ b/infra/context_processors.py @@ -107,12 +107,14 @@ def nav(request): if user is None or not user.is_authenticated: return {} from accounts.services import is_any_project_admin + from infra.chat import ENABLED as chat_enabled admin = is_any_project_admin(user) path = request.path + entries = _NAV + ((("chat", "Chat", "💬", ("/chat/",), False),) if chat_enabled else ()) items = [ {"url": reverse(name), "label": label, "icon": icon, "prefixes": prefixes} - for name, label, icon, prefixes, admin_only in _NAV + for name, label, icon, prefixes, admin_only in entries if admin or not admin_only ] diff --git a/infra/tests/test_chat.py b/infra/tests/test_chat.py new file mode 100644 index 00000000..c42271b3 --- /dev/null +++ b/infra/tests/test_chat.py @@ -0,0 +1,80 @@ +"""The optional Open WebUI module: off by default, forward-auth when on. + +Run: + SIMPLEAUDIT_LOCAL_SQLITE=1 uv run manage.py test infra.tests.test_chat +""" +from unittest.mock import patch + +from django.test import Client, TestCase + +from accounts.models import ProjectMembership +from infra.chat import EMAIL_HEADER, NAME_HEADER, ROLE_HEADER +from infra.tests.factories import MembershipFactory, ProjectFactory, UserFactory + + +class ChatDisabledTests(TestCase): + def test_routes_404_when_off(self): + user = UserFactory(username="off-user") + MembershipFactory(user=user, project=ProjectFactory()) + client = Client() + client.force_login(user) + self.assertEqual(client.get("/chat/").status_code, 404) + self.assertEqual(client.get("/chat/authz").status_code, 404) + + def test_no_sidebar_entry_when_off(self): + user = UserFactory(username="off-nav") + MembershipFactory(user=user, project=ProjectFactory()) + client = Client() + client.force_login(user) + self.assertNotContains(client.get("/"), 'href="/chat/"') + + +@patch("infra.chat.ENABLED", True) +class ChatEnabledTests(TestCase): + def setUp(self): + self.project = ProjectFactory() + self.client = Client() + + def _sign_in(self, role=ProjectMembership.Role.VIEWER, **kwargs): + user = UserFactory(**kwargs) + MembershipFactory(user=user, project=self.project, role=role) + self.client.force_login(user) + return user + + def test_authz_rejects_anonymous(self): + self.assertEqual(self.client.get("/chat/authz").status_code, 401) + + def test_authz_returns_identity_headers(self): + self._sign_in(username="member", first_name="Ada", last_name="L") + response = self.client.get("/chat/authz") + self.assertEqual(response.status_code, 200) + self.assertEqual(response[EMAIL_HEADER], "member@test.com") + self.assertEqual(response[NAME_HEADER], "Ada L") + self.assertEqual(response[ROLE_HEADER], "user") + + def test_workspace_admin_is_chat_admin(self): + self._sign_in(role=ProjectMembership.Role.ADMIN, username="boss") + self.assertEqual(self.client.get("/chat/authz")[ROLE_HEADER], "admin") + + def test_superuser_is_chat_admin(self): + self._sign_in(username="root", is_superuser=True) + self.assertEqual(self.client.get("/chat/authz")[ROLE_HEADER], "admin") + + def test_user_without_email_still_gets_one(self): + self._sign_in(username="anon", email="") + self.assertEqual(self.client.get("/chat/authz")[EMAIL_HEADER], "anon@studio.local") + + def test_page_embeds_the_chat_origin(self): + self._sign_in(username="viewer") + with patch("infra.chat.PUBLIC_URL", "http://localhost:8801"): + response = self.client.get("/chat/") + self.assertContains(response, 'src="http://localhost:8801"') + + def test_page_requires_sign_in(self): + response = self.client.get("/chat/") + self.assertEqual(response.status_code, 302) + self.assertIn("/login/", response["Location"]) + + def test_sidebar_links_to_chat(self): + self._sign_in(username="nav") + self.assertContains(self.client.get("/"), 'href="/chat/"') diff --git a/simpleaudit_studio/cli.py b/simpleaudit_studio/cli.py index 72fdf926..50a0dcfb 100644 --- a/simpleaudit_studio/cli.py +++ b/simpleaudit_studio/cli.py @@ -38,6 +38,10 @@ def main() -> None: "--mock", action="store_true", help="Use the built-in mock model server (zero-setup demo; results are simulated)", ) + parser.add_argument( + "--chat", action="store_true", + help="Also run Open WebUI, signed in as your Studio user (needs `uvx` or `open-webui`)", + ) parser.add_argument( "--no-browser", action="store_true", help="Do not auto-open the web UI in the default browser", @@ -46,6 +50,8 @@ def main() -> None: # Set local mode BEFORE Django reads settings os.environ["SIMPLEAUDIT_MINIMAL"] = "1" + if args.chat: + os.environ["SIMPLEAUDIT_CHAT"] = "embedded" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") os.environ.setdefault("DJANGO_SECRET_KEY", "local-insecure-key-change-for-shared-use") os.environ.setdefault("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1") @@ -121,6 +127,23 @@ def main() -> None: # Give the web server a moment to bind time.sleep(1) + # --- Optional: Open WebUI + its forward-auth proxy --- + chat_process = None + if args.chat: + from infra import chat as chat_config + from infra.chat_proxy import serve as serve_chat_proxy + from infra.chat_proxy import start_open_webui + + try: + chat_process = start_open_webui() + serve_chat_proxy(port) + print(f"💬 Chat (Open WebUI) at {chat_config.PUBLIC_URL} — also at /chat/ in Studio.") + print(" First start downloads it; the tab stays blank until it is ready.\n") + except (OSError, RuntimeError) as exc: + # Missing open-webui, a taken port, a failed spawn: chat is optional, + # so the rest of the stack still comes up. + print(f"⚠️ Chat could not start ({exc}); continuing without it.\n") + username = os.environ.get("BOOTSTRAP_USERNAME", "studio") password = os.environ.get("BOOTSTRAP_PASSWORD", "admin123") @@ -169,6 +192,8 @@ def _sigterm_handler(signum, frame): try: stop_embedded_hatchet() finally: + if chat_process is not None: + chat_process.terminate() if mock_server is not None: mock_server.shutdown() diff --git a/templates/chat.html b/templates/chat.html new file mode 100644 index 00000000..0a9aa352 --- /dev/null +++ b/templates/chat.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}Chat — SimpleAudit{% endblock %} +{% comment %} +Open WebUI runs on its own origin (it cannot be served under a sub-path), so it +is embedded here. Sign-in is automatic: the proxy in front of it identifies the +browser through /chat/authz. See infra/chat.py. +{% endcomment %} +{% block content %} + +{% endblock %} From a7f9d5c4a7203b1eda5d9004e6c40c7eca3e8ab6 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 17:32:52 +0200 Subject: [PATCH 06/65] feat: chat is part of the bundle, opted out rather than opted in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat module now runs by default where it can: `uvx simpleaudit-studio` starts Open WebUI alongside Studio, and `.env.example` sets both switches the Compose deployment needs, so a plain `docker compose up -d` brings the chat containers up without an extra --profile flag. Opting out: uvx simpleaudit-studio --disable-chat (--no-chat also accepted) SIMPLEAUDIT_CHAT=disabled (off/false/no/0/none too) comment out SIMPLEAUDIT_CHAT + COMPOSE_PROFILES in .env for Compose SIMPLEAUDIT_CHAT previously recognised only "embedded" and "docker", so any other spelling — including "disabled" — silently meant off. It now reads a documented set of disabling values and treats anything else as on, which is also what makes the CLI default work. Deployments that set neither the variable nor the Compose profile are unaffected: the routes still 404 and nothing extra runs. --- .env.example | 14 ++++++++++++++ README.md | 25 +++++++++++++------------ docker-compose.yml | 7 ++++--- docs/chat.md | 28 +++++++++++++++++++--------- infra/chat.py | 14 +++++++++++--- infra/tests/test_chat.py | 12 +++++++++++- simpleaudit_studio/cli.py | 21 ++++++++++++++------- 7 files changed, 86 insertions(+), 35 deletions(-) diff --git a/.env.example b/.env.example index a8d65056..be1b0b8d 100644 --- a/.env.example +++ b/.env.example @@ -90,6 +90,20 @@ WORKER_POOL=cpu # skips if demo runs already exist. Set to false to opt out. SEED_DEMO_AUDITS=true +# --- Chat (Open WebUI, optional) ------------------------------------------------ +# Embeds Open WebUI at /chat/, signed in as the Studio user (workspace admins +# become Open WebUI admins). Both lines below are needed: the first makes the +# web container serve /chat/, the second makes `docker compose up -d` start the +# chat containers without an extra --profile flag. Comment both out to disable. +# See docs/chat.md. +SIMPLEAUDIT_CHAT=docker +COMPOSE_PROFILES=chat +# The origin the browser opens (the chat proxy). Use your own hostname behind +# TLS; on a separate subdomain also set SESSION_COOKIE_DOMAIN. +SIMPLEAUDIT_CHAT_URL=http://localhost:8801 +# Where signed-out users are sent back to. +SIMPLEAUDIT_STUDIO_URL=http://localhost:8000 + # --- Optional observability ----------------------------------------------------- # --- Sentry error tracking & tracing (leave empty to disable) ------------------- diff --git a/README.md b/README.md index c50b8a55..9d12f436 100644 --- a/README.md +++ b/README.md @@ -104,27 +104,28 @@ cp .env.example .env docker compose up -d ``` -Services: Web UI (:8000), PostgreSQL, Hatchet queue (:8888), Worker. Optional profiles: `--profile mock` (mock model API), `--profile chat` (Open WebUI). +Services: Web UI (:8000), PostgreSQL, Hatchet queue (:8888), Worker. Chat (Open WebUI) is included via `.env`; optional profile `--profile mock` adds a mock model API. See [docs/deployment.md](docs/deployment.md) for production hardening, backups, and upgrades. -## 💬 Chat (optional) +## 💬 Chat -SimpleAudit Studio can embed [Open WebUI](https://openwebui.com) under `/chat/`, -signed in as your Studio user — workspace admins become Open WebUI admins. It is -off unless you turn it on, and nothing else changes when you do. +SimpleAudit Studio embeds [Open WebUI](https://openwebui.com) at `/chat/`, signed +in as your Studio user — workspace admins become Open WebUI admins. -```bash -uvx simpleaudit-studio --chat # local: starts Open WebUI alongside Studio -``` +The local one-liner bundles it; Docker Compose includes it when `.env` says so +(`.env.example` ships both lines set): ```bash -# Docker: add to .env, then -# SIMPLEAUDIT_CHAT=docker -# SIMPLEAUDIT_CHAT_URL=http://localhost:8801 -docker compose --profile chat up -d +uvx simpleaudit-studio # chat included +uvx simpleaudit-studio --disable-chat # without it + +docker compose up -d # includes chat via .env ``` +To leave it out of a Compose deployment, comment out `SIMPLEAUDIT_CHAT` and +`COMPOSE_PROFILES` in `.env`. + See [docs/chat.md](docs/chat.md) for how single sign-on works and what must stay private. diff --git a/docker-compose.yml b/docker-compose.yml index a72b0b0e..c85b8346 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -167,9 +167,10 @@ services: # instead of being printed once into a container nobody looks at. restart: unless-stopped - # Optional chat module: `docker compose --profile chat up`, with - # SIMPLEAUDIT_CHAT=docker and SIMPLEAUDIT_CHAT_URL=http://localhost:8801 in - # .env so the web service serves /chat/. See infra/chat.py. + # Optional chat module. .env carries both switches (SIMPLEAUDIT_CHAT=docker so + # the web service serves /chat/, COMPOSE_PROFILES=chat so these two containers + # are part of a plain `docker compose up -d`); comment them out to leave chat + # out entirely. See infra/chat.py. # # Open WebUI deliberately publishes NO port: chat-proxy is the only route to # it, because it trusts the identity headers on any request it receives. diff --git a/docs/chat.md b/docs/chat.md index cd5de069..7d4ad401 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -1,9 +1,11 @@ # Chat (Open WebUI) -An optional module that embeds [Open WebUI](https://openwebui.com) in Studio at -`/chat/`, signed in as the Studio user. It is disabled by default: with -`SIMPLEAUDIT_CHAT` unset, `/chat/` and `/chat/authz` return 404, the sidebar has -no Chat entry, and nothing extra runs. +A module that embeds [Open WebUI](https://openwebui.com) in Studio at `/chat/`, +signed in as the Studio user. It is part of the bundle the local one-liner starts +and of the Compose deployment `.env.example` describes, and it can be left out +entirely: with `SIMPLEAUDIT_CHAT` set to `off` (or `disabled`, `false`, `no`, +`0`) or unset in a deployment that does not set it, `/chat/` and `/chat/authz` +return 404, the sidebar has no Chat entry, and nothing extra runs. ## Why it is an iframe, not a sub-path @@ -52,7 +54,8 @@ own — if you put your own proxy in front, it must do the same. ## Local (no Docker) ```bash -uvx simpleaudit-studio --chat +uvx simpleaudit-studio # chat is part of the bundle +uvx simpleaudit-studio --disable-chat # leave it out ``` Starts Open WebUI (via `open-webui` if installed, otherwise `uvx`) on @@ -71,15 +74,22 @@ long-polling. Chat responses stream over SSE and are unaffected. ## Docker +`.env.example` ships with both switches set, so the ordinary command starts chat +too: + ```bash # .env -SIMPLEAUDIT_CHAT=docker +SIMPLEAUDIT_CHAT=docker # web serves /chat/ +COMPOSE_PROFILES=chat # the two chat containers start SIMPLEAUDIT_CHAT_URL=http://localhost:8801 # what the browser opens SIMPLEAUDIT_STUDIO_URL=http://localhost:8000 # where signed-out users are sent -docker compose --profile chat up -d +docker compose up -d ``` +Comment both switches out to deploy without chat; they are independent, and +setting only `SIMPLEAUDIT_CHAT` gives a `/chat/` page with nothing behind it. + This runs `open-webui` (no published port) behind `chat-proxy`, a Caddy container configured by [deploy/compose/Caddyfile.chat](../deploy/compose/Caddyfile.chat). Studio itself only serves the iframe page and `/chat/authz`. @@ -92,7 +102,7 @@ cookie. Different registrable domains will not work. | Variable | Default | Meaning | |---------------------------------|--------------------------|--------------------------------------------| -| `SIMPLEAUDIT_CHAT` | `off` | `embedded`, `docker` or `off` | +| `SIMPLEAUDIT_CHAT` | `embedded` (CLI), unset elsewhere | `embedded`, `docker`, or `off`/`disabled`/`false`/`no`/`0` | | `SIMPLEAUDIT_CHAT_URL` | `http://localhost:8801` | the origin the iframe loads | | `SIMPLEAUDIT_CHAT_UPSTREAM` | `http://127.0.0.1:8080` | where Open WebUI listens | | `SIMPLEAUDIT_CHAT_PROXY_PORT` | `8801` | the proxy's port (both modes) | @@ -102,7 +112,7 @@ cookie. Different registrable domains will not work. ## Removing it -Leave `SIMPLEAUDIT_CHAT` unset. To drop the code, delete `infra/chat.py`, +Set `SIMPLEAUDIT_CHAT=disabled`, or pass `--disable-chat` to the CLI. To drop the code, delete `infra/chat.py`, `infra/chat_proxy.py`, `infra/tests/test_chat.py`, `templates/chat.html`, `deploy/compose/Caddyfile.chat`, the two `chat/` routes in `config/urls.py`, the Chat entry in `infra/context_processors.py`, the `--chat` flag in diff --git a/infra/chat.py b/infra/chat.py index cfa70274..0d0647d7 100644 --- a/infra/chat.py +++ b/infra/chat.py @@ -22,10 +22,11 @@ network with no published port (docker mode). Modes (``SIMPLEAUDIT_CHAT``): - off the default — the URLs 404 and nothing starts embedded the CLI starts Open WebUI and the proxy in infra.chat_proxy docker an external proxy (Caddy) does forward-auth; Studio only serves the iframe page and /chat/authz + off the URLs 404 and nothing starts — also "disabled", "false", "no", + "0", or leaving the variable unset, which is the default """ from __future__ import annotations @@ -35,8 +36,15 @@ from django.views.generic import TemplateView # --- Configuration --------------------------------------------------------- -MODE = (os.environ.get("SIMPLEAUDIT_CHAT") or "off").strip().lower() -ENABLED = MODE in ("embedded", "docker") +#: Spellings of "no chat", so nobody has to guess which one this reads. +DISABLED_VALUES = frozenset({"", "off", "disabled", "disable", "false", "no", "none", "0"}) + +def is_disabled(value: str | None) -> bool: + return (value or "").strip().lower() in DISABLED_VALUES + + +MODE = (os.environ.get("SIMPLEAUDIT_CHAT") or "").strip().lower() +ENABLED = not is_disabled(MODE) #: Where Open WebUI itself listens. Never exposed to browsers. UPSTREAM = os.environ.get("SIMPLEAUDIT_CHAT_UPSTREAM", "http://127.0.0.1:8080").rstrip("/") diff --git a/infra/tests/test_chat.py b/infra/tests/test_chat.py index c42271b3..ffd4290c 100644 --- a/infra/tests/test_chat.py +++ b/infra/tests/test_chat.py @@ -8,10 +8,20 @@ from django.test import Client, TestCase from accounts.models import ProjectMembership -from infra.chat import EMAIL_HEADER, NAME_HEADER, ROLE_HEADER +from infra.chat import EMAIL_HEADER, NAME_HEADER, ROLE_HEADER, is_disabled from infra.tests.factories import MembershipFactory, ProjectFactory, UserFactory +class ChatSwitchTests(TestCase): + def test_spellings_that_turn_chat_off(self): + for value in (None, "", "off", "disabled", "DISABLED", " no ", "false", "0"): + self.assertTrue(is_disabled(value), value) + + def test_spellings_that_turn_chat_on(self): + for value in ("embedded", "docker", "on"): + self.assertFalse(is_disabled(value), value) + + class ChatDisabledTests(TestCase): def test_routes_404_when_off(self): user = UserFactory(username="off-user") diff --git a/simpleaudit_studio/cli.py b/simpleaudit_studio/cli.py index 50a0dcfb..fa66d81f 100644 --- a/simpleaudit_studio/cli.py +++ b/simpleaudit_studio/cli.py @@ -39,8 +39,8 @@ def main() -> None: help="Use the built-in mock model server (zero-setup demo; results are simulated)", ) parser.add_argument( - "--chat", action="store_true", - help="Also run Open WebUI, signed in as your Studio user (needs `uvx` or `open-webui`)", + "--disable-chat", "--no-chat", dest="disable_chat", action="store_true", + help="Do not run the bundled chat (Open WebUI); /chat/ stays unavailable", ) parser.add_argument( "--no-browser", action="store_true", @@ -50,8 +50,12 @@ def main() -> None: # Set local mode BEFORE Django reads settings os.environ["SIMPLEAUDIT_MINIMAL"] = "1" - if args.chat: - os.environ["SIMPLEAUDIT_CHAT"] = "embedded" + # Chat is part of the bundle; --disable-chat (or SIMPLEAUDIT_CHAT=disabled) + # opts out. + if args.disable_chat: + os.environ["SIMPLEAUDIT_CHAT"] = "off" + else: + os.environ.setdefault("SIMPLEAUDIT_CHAT", "embedded") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") os.environ.setdefault("DJANGO_SECRET_KEY", "local-insecure-key-change-for-shared-use") os.environ.setdefault("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1") @@ -127,10 +131,11 @@ def main() -> None: # Give the web server a moment to bind time.sleep(1) - # --- Optional: Open WebUI + its forward-auth proxy --- + # --- Chat: Open WebUI + its forward-auth proxy --- chat_process = None - if args.chat: - from infra import chat as chat_config + from infra import chat as chat_config + + if chat_config.ENABLED: from infra.chat_proxy import serve as serve_chat_proxy from infra.chat_proxy import start_open_webui @@ -155,6 +160,8 @@ def main() -> None: print(f"│ Web UI: http://localhost:{port} │") print(f"│ Login: {username} / {password:<20s}│") print(f"│ API Docs: http://localhost:{port}/api/schema/ │") + if chat_process is not None: + print(f"│ Chat: http://localhost:{port}/chat/ │") print("│ │") if args.mock: print("│ Models: Built-in mock (simulated results) │") From 349a1465afbb516bb87ba343957fe5b98ec33153 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 17:41:30 +0200 Subject: [PATCH 07/65] feat: say what the bundled chat is doing while it starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open WebUI takes minutes to come up on a first run — it is downloaded, then it migrates its database — and until now the console said only "first start downloads it" before the chat's own log poured into the terminal. The CLI now prints that chat is starting, warns on a first run that this means a ~1 GB download, and names the folders its data and log live in. A background thread polls until Open WebUI answers and prints either the ready line with the /chat/ URL, or why it stopped, pointing at the log. Studio and the worker come up meanwhile, as before. Open WebUI's own output now goes to openwebui/server.log instead of the console, which also makes a failure readable after the fact. The readiness poll uses urllib rather than httpx so it does not log a request line every two seconds. --- docs/chat.md | 9 +++++-- infra/chat_proxy.py | 53 ++++++++++++++++++++++++++++++++++----- simpleaudit_studio/cli.py | 49 ++++++++++++++++++++++++++++-------- 3 files changed, 93 insertions(+), 18 deletions(-) diff --git a/docs/chat.md b/docs/chat.md index 7d4ad401..76521bf3 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -61,8 +61,13 @@ uvx simpleaudit-studio --disable-chat # leave it out Starts Open WebUI (via `open-webui` if installed, otherwise `uvx`) on 127.0.0.1:8080, plus the forward-auth proxy from `infra/chat_proxy.py` on :8801. Open WebUI's data lives beside Studio's, in `~/.simpleaudit-studio/openwebui/`, -and it runs from that folder so its signing key stays there too. The first start -downloads it (a few hundred MB), so `/chat/` stays blank for a minute or two. +and it runs from that folder so its signing key stays there too. + +The CLI reports what it is doing: it says when chat is starting, warns on a first +run that Open WebUI is being downloaded (~1 GB via `uvx`, a few minutes), prints +where its data and log live, and prints one line when `/chat/` is actually ready +— or why it stopped. Open WebUI's own output goes to `openwebui/server.log`, not +the console. Studio and the worker come up while all this happens. `open-webui serve` ignores `HOST`/`PORT` and defaults to **0.0.0.0**:8080, so Studio passes `--host`/`--port` explicitly. If you override the command with diff --git a/infra/chat_proxy.py b/infra/chat_proxy.py index 53cd0f9b..80e50fd2 100644 --- a/infra/chat_proxy.py +++ b/infra/chat_proxy.py @@ -1,8 +1,8 @@ """The forward-auth proxy that fronts Open WebUI in embedded mode. Docker deployments use Caddy for this (see deploy/compose/Caddyfile.chat); this -module is the no-Docker equivalent, so ``uvx simpleaudit-studio --chat`` needs -nothing but Python. Both do the same three things: +module is the no-Docker equivalent, so ``uvx simpleaudit-studio`` needs nothing +but Python. Both do the same three things: 1. strip any client-supplied trusted header (otherwise anyone could forge one), 2. ask Studio ``GET /chat/authz`` who the browser is, forwarding its cookies, @@ -19,7 +19,9 @@ import shutil import subprocess import threading +import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path from urllib.parse import urlsplit import httpx @@ -121,15 +123,53 @@ def serve(studio_port: int) -> ThreadingHTTPServer: return server +def home_dir() -> Path: + """Open WebUI's data folder, beside Studio's own.""" + from simpleaudit_studio.paths import data_dir + + return data_dir() / "openwebui" + + +def log_path() -> Path: + """Where Open WebUI's own output goes — it is far too chatty for the console.""" + return home_dir() / "server.log" + + +def is_first_run() -> bool: + """True when Open WebUI has never started here, so it has to be fetched.""" + return not (home_dir() / "webui.db").exists() + + +def wait_until_ready(process: subprocess.Popen, timeout: float = 900.0) -> bool: + """Poll Open WebUI until it answers, the process dies, or time runs out. + + Uses urllib rather than httpx so a start-up that takes minutes does not + write a request log line every two seconds to the console. + """ + import urllib.error + import urllib.request + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None: + return False + try: + with urllib.request.urlopen(chat.UPSTREAM + "/health", timeout=3.0) as response: + if response.status == 200: + return True + except (urllib.error.URLError, OSError): + pass + time.sleep(2.0) + return False + + def start_open_webui() -> subprocess.Popen: """Start Open WebUI bound to loopback, in trusted-header mode. Uses the ``open-webui`` command when it is installed, otherwise ``uvx`` fetches it on first run. SIMPLEAUDIT_CHAT_CMD overrides both. """ - from simpleaudit_studio.paths import data_dir - - home = data_dir() / "openwebui" + home = home_dir() home.mkdir(parents=True, exist_ok=True) host = urlsplit(chat.UPSTREAM).hostname or "127.0.0.1" port = urlsplit(chat.UPSTREAM).port or 8080 @@ -165,4 +205,5 @@ def start_open_webui() -> subprocess.Popen: # directory, with no setting for it: running it from its own data folder # keeps that out of wherever Studio was started and stable across restarts # (a new key signs every session out). - return subprocess.Popen(argv, env=env, cwd=str(home)) + log = log_path().open("a") + return subprocess.Popen(argv, env=env, cwd=str(home), stdout=log, stderr=subprocess.STDOUT) diff --git a/simpleaudit_studio/cli.py b/simpleaudit_studio/cli.py index fa66d81f..bfad5850 100644 --- a/simpleaudit_studio/cli.py +++ b/simpleaudit_studio/cli.py @@ -136,18 +136,23 @@ def main() -> None: from infra import chat as chat_config if chat_config.ENABLED: - from infra.chat_proxy import serve as serve_chat_proxy - from infra.chat_proxy import start_open_webui - + from infra import chat_proxy + + print("💬 Starting chat (Open WebUI)...") + if chat_proxy.is_first_run(): + print(" First start downloads it (~1 GB via uvx) and can take a few minutes.") + print(" Studio is usable right away; /chat/ works once the download finishes.") + print(f" Its data: {chat_proxy.home_dir()}") + print(f" Its log: {chat_proxy.log_path()}") try: - chat_process = start_open_webui() - serve_chat_proxy(port) - print(f"💬 Chat (Open WebUI) at {chat_config.PUBLIC_URL} — also at /chat/ in Studio.") - print(" First start downloads it; the tab stays blank until it is ready.\n") + chat_process = start_chat(chat_proxy, port) except (OSError, RuntimeError) as exc: - # Missing open-webui, a taken port, a failed spawn: chat is optional, - # so the rest of the stack still comes up. - print(f"⚠️ Chat could not start ({exc}); continuing without it.\n") + # No open-webui to run, a taken port, a failed spawn: chat is one + # part of the stack, so the rest still comes up without it. + print(f"⚠️ Chat could not start ({exc}); continuing without it.") + print(" Skip it with --disable-chat.\n") + else: + print() username = os.environ.get("BOOTSTRAP_USERNAME", "studio") password = os.environ.get("BOOTSTRAP_PASSWORD", "admin123") @@ -205,6 +210,30 @@ def _sigterm_handler(signum, frame): mock_server.shutdown() +def start_chat(chat_proxy, studio_port: int): + """Start Open WebUI and its proxy, and report readiness in the background. + + Open WebUI takes minutes to be ready on a first run (it is fetched, then it + migrates its database), so the wait happens in a thread: Studio and the + worker come up meanwhile, and one line says when /chat/ is live. + """ + process = chat_proxy.start_open_webui() + chat_proxy.serve(studio_port) + + def report(): + if chat_proxy.wait_until_ready(process): + print(f"\n✅ Chat is ready — http://localhost:{studio_port}/chat/\n") + elif process.poll() is not None: + print(f"\n⚠️ Chat stopped (exit {process.returncode}). Studio is unaffected.") + print(f" What happened: {chat_proxy.log_path()}\n") + else: + print("\n⚠️ Chat is still not answering. Studio is unaffected.") + print(f" What it is doing: {chat_proxy.log_path()}\n") + + threading.Thread(target=report, daemon=True).start() + return process + + def _check_port_available(port: int) -> None: """Exit early with a clear message if the web port is already in use.""" import socket From ef45dbe3e570ccd2ac2c43f6ec78763cc7b77c57 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 17:49:28 +0200 Subject: [PATCH 08/65] fix: manage Open WebUI's lifecycle like the embedded Hatchet engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat was started with a bare Popen and stopped with terminate(), which left two ways to orphan it. `uvx open-webui` is a launcher with the real server as its child, so terminate() signalled the launcher and left the server holding the port; and a hard-killed run (SIGKILL, crash, closed terminal) never got to stop anything at all, so the next start failed to bind. Open WebUI now follows the same pattern as the embedded engine: - one instance per process, guarded by a lock; a second start returns the first - started in its own process group, so stopping it reaches the real server - stopped by the CLI's shutdown path and by atexit, SIGTERM then SIGKILL - its PID recorded in openwebui/open-webui.pid, so the next start can stop a leftover from a killed run — only when its parent is gone, never one that belongs to another running Studio Verified against the real Open WebUI: explicit stop, exit without stopping, and SIGKILL of the Studio process followed by a restart all end with no surviving process and a free port. --- docs/chat.md | 12 ++++ infra/chat_proxy.py | 120 ++++++++++++++++++++++++++++++++++++-- simpleaudit_studio/cli.py | 4 +- 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/docs/chat.md b/docs/chat.md index 76521bf3..37949d10 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -74,6 +74,18 @@ Studio passes `--host`/`--port` explicitly. If you override the command with `SIMPLEAUDIT_CHAT_CMD`, pass those flags yourself — binding it to all interfaces is what the warning above is about. +Open WebUI is managed like the embedded Hatchet engine: one instance per Studio +process, started in its own process group, and stopped on the way out — by the +CLI's shutdown and by `atexit`, so Ctrl+C, `kill`, and an unhandled exit all take +it with them. The group matters because `uvx` is only a launcher; signalling it +alone would leave the server running. + +A run that is hard-killed (SIGKILL, a crash, a closed terminal) cannot stop +anything, so its Open WebUI keeps holding the port. The next start finds it +through `openwebui/open-webui.pid` and stops it first — but only when it is a +genuine leftover, i.e. its parent is gone. One that belongs to another running +Studio is left alone, and that start fails on the port instead. + WebSockets are not proxied; Open WebUI's Socket.IO client falls back to HTTP long-polling. Chat responses stream over SSE and are unaffected. diff --git a/infra/chat_proxy.py b/infra/chat_proxy.py index 80e50fd2..5ecb5d3b 100644 --- a/infra/chat_proxy.py +++ b/infra/chat_proxy.py @@ -14,9 +14,11 @@ """ from __future__ import annotations +import atexit import logging import os import shutil +import signal import subprocess import threading import time @@ -30,6 +32,13 @@ logger = logging.getLogger(__name__) +# Open WebUI is managed like the embedded Hatchet engine: one instance per +# process, stopped on the way out (atexit as well as the CLI's own shutdown), +# and a run that was hard-killed has its leftovers cleaned up by the next start. +_process: subprocess.Popen | None = None +_log_file = None +_process_lock = threading.Lock() + # Connection-level headers that must not be forwarded (RFC 9110 §7.6.1). _HOP_BY_HOP = frozenset({ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", @@ -140,6 +149,87 @@ def is_first_run() -> bool: return not (home_dir() / "webui.db").exists() +def pid_file() -> Path: + """Records the running Open WebUI, so the next start can clean up after a + run that never got to stop it.""" + return home_dir() / "open-webui.pid" + + +def _stop_stale() -> None: + """Stop an Open WebUI left behind by a hard-killed run. + + It holds the upstream port, so the next start would fail to bind. Only a + leftover is touched: a process whose parent is gone. One with a live parent + belongs to another running Studio and is left alone (that start then fails + on the port, which is the honest outcome). + + Best-effort: any error is logged and swallowed so startup proceeds. + """ + from infra.minimal_config import _orphaned, _parent_pid, _pid_alive + + file = pid_file() + try: + if not file.exists(): + return + raw = file.read_text().strip() + pid = int(raw) if raw.isdigit() else 0 + if pid and _pid_alive(pid): + parent = _parent_pid(pid) + if parent is not None and not _orphaned(parent): + return + logger.warning("Stopping orphaned Open WebUI (PID %d) left by a killed run", pid) + _terminate(pid) + file.unlink(missing_ok=True) + except Exception: # cleanup must never block startup + logger.warning("Could not clean up a leftover Open WebUI", exc_info=True) + + +def _terminate(pid: int, grace: float = 10.0) -> None: + """SIGTERM the process group, then SIGKILL whatever is still there. + + The group matters: ``uvx open-webui`` is a launcher with the real server as + its child, so signalling only the launcher leaves the server running. + """ + from infra.minimal_config import _pid_alive, _wait_gone + + for sig, wait in ((signal.SIGTERM, grace), (signal.SIGKILL, 5.0)): + if not _pid_alive(pid): + return + try: + os.killpg(os.getpgid(pid), sig) + except (ProcessLookupError, PermissionError): + return + except (AttributeError, OSError): + # No process groups (Windows): fall back to the process itself. + try: + os.kill(pid, sig) + except (ProcessLookupError, PermissionError): + return + _wait_gone([pid], wait) + + +def stop_open_webui() -> None: + """Stop the Open WebUI this process started. Safe to call more than once.""" + global _process, _log_file + with _process_lock: + process, _process = _process, None + if process is not None and process.poll() is None: + _terminate(process.pid) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + logger.warning("Open WebUI (PID %d) did not exit", process.pid) + if _log_file is not None: + _log_file.close() + _log_file = None + if process is not None: + pid_file().unlink(missing_ok=True) + + +# Best-effort clean shutdown even if the caller forgets to stop explicitly. +atexit.register(stop_open_webui) + + def wait_until_ready(process: subprocess.Popen, timeout: float = 900.0) -> bool: """Poll Open WebUI until it answers, the process dies, or time runs out. @@ -168,9 +258,24 @@ def start_open_webui() -> subprocess.Popen: Uses the ``open-webui`` command when it is installed, otherwise ``uvx`` fetches it on first run. SIMPLEAUDIT_CHAT_CMD overrides both. + + One instance per process: a second call returns the running one. Leftovers + from a hard-killed previous run are stopped first. """ - home = home_dir() - home.mkdir(parents=True, exist_ok=True) + with _process_lock: + if _process is not None and _process.poll() is None: + return _process + + home = home_dir() + home.mkdir(parents=True, exist_ok=True) + _stop_stale() + return _spawn(home) + + +def _spawn(home: Path) -> subprocess.Popen: + """Build the command and environment, and start the server.""" + global _process, _log_file + host = urlsplit(chat.UPSTREAM).hostname or "127.0.0.1" port = urlsplit(chat.UPSTREAM).port or 8080 @@ -205,5 +310,12 @@ def start_open_webui() -> subprocess.Popen: # directory, with no setting for it: running it from its own data folder # keeps that out of wherever Studio was started and stable across restarts # (a new key signs every session out). - log = log_path().open("a") - return subprocess.Popen(argv, env=env, cwd=str(home), stdout=log, stderr=subprocess.STDOUT) + _log_file = log_path().open("a") + # Its own process group, so stopping it reaches the server that `uvx` (or + # any other launcher) starts as a child, not just the launcher. + _process = subprocess.Popen( + argv, env=env, cwd=str(home), + stdout=_log_file, stderr=subprocess.STDOUT, start_new_session=True, + ) + pid_file().write_text(str(_process.pid)) + return _process diff --git a/simpleaudit_studio/cli.py b/simpleaudit_studio/cli.py index bfad5850..c2354b1e 100644 --- a/simpleaudit_studio/cli.py +++ b/simpleaudit_studio/cli.py @@ -205,7 +205,9 @@ def _sigterm_handler(signum, frame): stop_embedded_hatchet() finally: if chat_process is not None: - chat_process.terminate() + from infra.chat_proxy import stop_open_webui + + stop_open_webui() if mock_server is not None: mock_server.shutdown() From 7160a76e76488d69686f720a0be018bee7eb3747 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 17:59:20 +0200 Subject: [PATCH 09/65] fix: flush the chat readiness line It is printed from a background thread minutes after startup, and stdout is block-buffered when the CLI's output is a file or a pipe rather than a terminal, so the line could sit unwritten for the rest of the run. --- simpleaudit_studio/cli.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/simpleaudit_studio/cli.py b/simpleaudit_studio/cli.py index c2354b1e..c8ed52c3 100644 --- a/simpleaudit_studio/cli.py +++ b/simpleaudit_studio/cli.py @@ -223,14 +223,16 @@ def start_chat(chat_proxy, studio_port: int): chat_proxy.serve(studio_port) def report(): + # flush: this lands minutes later, and stdout is block-buffered when the + # CLI's output is a file or a pipe rather than a terminal. if chat_proxy.wait_until_ready(process): - print(f"\n✅ Chat is ready — http://localhost:{studio_port}/chat/\n") + print(f"\n✅ Chat is ready — http://localhost:{studio_port}/chat/\n", flush=True) elif process.poll() is not None: print(f"\n⚠️ Chat stopped (exit {process.returncode}). Studio is unaffected.") - print(f" What happened: {chat_proxy.log_path()}\n") + print(f" What happened: {chat_proxy.log_path()}\n", flush=True) else: print("\n⚠️ Chat is still not answering. Studio is unaffected.") - print(f" What it is doing: {chat_proxy.log_path()}\n") + print(f" What it is doing: {chat_proxy.log_path()}\n", flush=True) threading.Thread(target=report, daemon=True).start() return process From 23a84b74ebabbbadd9b51665ae763639a2481506 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 18:03:43 +0200 Subject: [PATCH 10/65] feat: give chat the whole screen, with one way back Open WebUI brings its own sidebar, header and settings, so wrapping it in the Studio shell put two navigations on the same edges and left the chat itself in a boxed iframe. The page now stands alone: a slim bar with a centred "Back to Studio" link, and the chat filling everything below it. The page no longer extends base.html, so it carries its own small stylesheet rather than pulling in Tailwind, htmx and the sidebar for one link. --- templates/chat.html | 56 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/templates/chat.html b/templates/chat.html index 0a9aa352..d3977785 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -1,13 +1,53 @@ -{% extends "base.html" %} -{% block title %}Chat — SimpleAudit{% endblock %} +{% load static %} {% comment %} +Chat is full screen: Open WebUI has its own sidebar, header and settings, so the +Studio shell around it would be two navigations fighting for the same edges. The +only Studio chrome is a slim bar with the way back. + Open WebUI runs on its own origin (it cannot be served under a sub-path), so it is embedded here. Sign-in is automatic: the proxy in front of it identifies the browser through /chat/authz. See infra/chat.py. {% endcomment %} -{% block content %} - -{% endblock %} + + + + + + Chat — SimpleAudit + + + + + +
+ + + + Back to Studio + +
+ + + From 1abf84555242e756e0ff132447da200b5ed9fcfc Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 18:07:52 +0200 Subject: [PATCH 11/65] feat: float the way back over the chat instead of reserving a bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat now fills the viewport and the "Back to Studio" link sits on top of it as a small translucent pill, centred at the top. Nothing is reserved for a bar, so Open WebUI keeps the full height it expects. Only the pill takes clicks — the strip around it passes them through to the chat — and it sits at 75% opacity until hovered or focused, so it stays out of the way of Open WebUI's own header. --- templates/chat.html | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/templates/chat.html b/templates/chat.html index d3977785..39c77116 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -21,23 +21,34 @@ * { box-sizing: border-box; } html, body { height: 100%; } body { - margin: 0; display: flex; flex-direction: column; background: var(--surface); + margin: 0; background: var(--surface); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } + /* The chat gets the whole viewport; the way back floats over it as a small + pill, so nothing is reserved for a bar. Only the pill takes clicks. */ header { - flex: none; height: 2.75rem; display: flex; align-items: center; justify-content: center; - background: var(--raised); border-bottom: 1px solid var(--line); + position: fixed; top: .4rem; left: 50%; transform: translateX(-50%); + z-index: 10; pointer-events: none; } .back { - display: inline-flex; align-items: center; gap: .5rem; padding: .3rem .7rem; - border-radius: .5rem; font-size: .8125rem; font-weight: 500; - color: var(--text); text-decoration: none; transition: color .15s, background-color .15s; + display: inline-flex; align-items: center; gap: .4rem; padding: .25rem .65rem .25rem .5rem; + border: 1px solid var(--line); border-radius: 999px; + /* Translucent so it reads as floating over the chat, not pinned to it. + The plain colour first, for browsers without color-mix. */ + background: var(--raised); + background: color-mix(in srgb, var(--raised) 82%, transparent); + backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); + font-size: .75rem; font-weight: 500; line-height: 1; opacity: .75; + color: var(--text); text-decoration: none; pointer-events: auto; + transition: color .15s, border-color .15s, opacity .15s; } - .back:hover { color: var(--text-strong); background: #242836; } - .back img { width: 1.15rem; height: 1.15rem; border-radius: .25rem; } - .back .arrow { font-size: .9rem; line-height: 1; } - iframe { flex: 1; width: 100%; border: 0; display: block; } + .back:hover, .back:focus-visible { + opacity: 1; color: var(--text-strong); border-color: #394052; + } + .back img { width: 1rem; height: 1rem; border-radius: .2rem; } + .back .arrow { font-size: .8rem; line-height: 1; } + iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; } From 05db5d9a3908dd2ea24a0fb4c3b2dd6a34cb607b Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 18:15:32 +0200 Subject: [PATCH 12/65] refactor: move chat into its own Django app, and scaffold the API in both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module was spread across infra/ (chat.py, chat_proxy.py, chat_api.py), two management command folders, infra/tests and the shared templates folder, which is the wrong shape for something that is meant to be optional and removable. It is now one app: chat/ config.py what the module is configured to do, and who you are views.py urls.py the iframe page and /chat/authz proxy.py the forward-auth proxy + Open WebUI's lifecycle api.py talking to Open WebUI's API, both directions management/ sync_chat_models, chat_knowledge templates/ tests/ The app is installed either way so its templates, commands and tests resolve; SIMPLEAUDIT_CHAT still decides whether anything runs. Removing the feature is now deleting a folder and four one-line references. chat/api.py is new, and is the scaffolding for tighter integration. It authenticates the way the proxy makes the browser authenticate — the trusted headers to /api/v1/auths/signin, then the token it returns — so there is no API key to provision and every call runs as a real Open WebUI user. Push: a Studio model connection is a base URL plus a key, which is exactly Open WebUI's OpenAI-compatible provider config, so `sync_chat_models` writes connections into OPENAI_API_BASE_URLS/KEYS/CONFIGS. Those lists are hand-editable in Open WebUI, so each pushed entry carries a simpleaudit_connection_id marker: a sync replaces the marked entries and keeps the rest, which the merge is tested for in both directions. Pull: `chat_knowledge` lists knowledge bases and their files, normalised to plain dicts so Studio code never sees Open WebUI's schema. Nothing syncs automatically yet — both directions are explicit commands until the shape settles. --- chat/__init__.py | 6 + chat/api.py | 205 +++++++++++++++++++ chat/apps.py | 8 + infra/chat.py => chat/config.py | 45 +--- chat/management/__init__.py | 0 chat/management/commands/__init__.py | 0 chat/management/commands/chat_knowledge.py | 59 ++++++ chat/management/commands/sync_chat_models.py | 65 ++++++ infra/chat_proxy.py => chat/proxy.py | 2 +- {templates => chat/templates/chat}/chat.html | 2 +- chat/tests/__init__.py | 0 chat/tests/test_api.py | 185 +++++++++++++++++ {infra => chat}/tests/test_chat.py | 8 +- chat/urls.py | 13 ++ chat/views.py | 47 +++++ config/settings.py | 4 + config/urls.py | 12 +- docs/chat.md | 70 ++++++- infra/context_processors.py | 2 +- pyproject.toml | 1 + simpleaudit_studio/cli.py | 6 +- 21 files changed, 674 insertions(+), 66 deletions(-) create mode 100644 chat/__init__.py create mode 100644 chat/api.py create mode 100644 chat/apps.py rename infra/chat.py => chat/config.py (72%) create mode 100644 chat/management/__init__.py create mode 100644 chat/management/commands/__init__.py create mode 100644 chat/management/commands/chat_knowledge.py create mode 100644 chat/management/commands/sync_chat_models.py rename infra/chat_proxy.py => chat/proxy.py (99%) rename {templates => chat/templates/chat}/chat.html (98%) create mode 100644 chat/tests/__init__.py create mode 100644 chat/tests/test_api.py rename {infra => chat}/tests/test_chat.py (92%) create mode 100644 chat/urls.py create mode 100644 chat/views.py diff --git a/chat/__init__.py b/chat/__init__.py new file mode 100644 index 00000000..0e958a2c --- /dev/null +++ b/chat/__init__.py @@ -0,0 +1,6 @@ +"""Open WebUI, embedded in Studio and signed in with Studio's identity. + +An optional module: with SIMPLEAUDIT_CHAT unset or disabled, its URLs 404, the +sidebar has no Chat entry and nothing extra runs. See docs/chat.md. +""" +default_app_config = "chat.apps.ChatConfig" diff --git a/chat/api.py b/chat/api.py new file mode 100644 index 00000000..ad1c0fb1 --- /dev/null +++ b/chat/api.py @@ -0,0 +1,205 @@ +"""Talking to Open WebUI's API from Studio — both directions. + +Studio already sits on the trusted side of the chat proxy, so it authenticates +the same way the proxy makes the browser authenticate: POST the trusted identity +headers to ``/api/v1/auths/signin`` and use the token that comes back. No API key +to provision, no second set of credentials, and the call runs as a real Open +WebUI user with that user's role. + + Studio ──(X-Studio-* headers)──► /api/v1/auths/signin ──► token + ──(Bearer token)────────► the rest of the API + +Push (Studio → Open WebUI): a Studio model connection is a base URL plus a key, +which is exactly Open WebUI's OpenAI-compatible provider config, so connections +map onto ``OPENAI_API_BASE_URLS``/``OPENAI_API_KEYS``/``OPENAI_API_CONFIGS``. +Open WebUI keeps those as parallel lists that anyone can also edit by hand, so +each pushed entry carries a marker (``simpleaudit_connection_id``) in its config. +A sync replaces the marked entries and leaves everything else alone. + +Pull (Open WebUI → Studio): knowledge bases are read through ``/api/v1/knowledge`` +and normalised to plain dicts, so callers never see Open WebUI's schema. + +This module never talks to the proxy — it addresses Open WebUI directly on +``chat.UPSTREAM``, which is reachable from the Studio process only. +""" +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from chat import config as chat + +logger = logging.getLogger(__name__) + +#: Marks a provider entry as one Studio owns, so a sync can replace exactly +#: those and leave hand-added ones untouched. +STUDIO_MARKER = "simpleaudit_connection_id" + +_TIMEOUT = httpx.Timeout(30.0, connect=10.0) + + +class ChatAPIError(RuntimeError): + """Open WebUI refused or could not be reached.""" + + +class ChatAPI: + """An Open WebUI session for one Studio user. + + ``ChatAPI.as_user(user)`` is the normal entry point. Pushing provider config + needs an Open WebUI admin, which means a Studio superuser or a workspace + admin (see ``chat.config.identity``). + """ + + def __init__(self, identity: dict[str, str], *, base_url: str | None = None): + self.identity = identity + self.base_url = (base_url or chat.UPSTREAM).rstrip("/") + self._token: str | None = None + + @classmethod + def as_user(cls, user) -> ChatAPI: + return cls(chat.identity(user)) + + # --- plumbing ---------------------------------------------------------- + def sign_in(self) -> dict[str, Any]: + """Exchange the trusted headers for a token. Creates the account on first use.""" + response = self._send( + "POST", "/api/v1/auths/signin", + json={"email": "", "password": ""}, # the headers carry the identity + headers=self.identity, + ) + self._token = response.get("token") + if not self._token: + raise ChatAPIError("Open WebUI signed us in but returned no token.") + return response + + def request(self, method: str, path: str, json: Any | None = None) -> Any: + if self._token is None: + self.sign_in() + return self._send(method, path, json=json, + headers={"Authorization": f"Bearer {self._token}"}) + + def _send(self, method: str, path: str, *, json: Any | None, headers: dict[str, str]) -> Any: + url = f"{self.base_url}{path}" + try: + response = httpx.request(method, url, json=json, headers=headers, timeout=_TIMEOUT) + except httpx.HTTPError as exc: + raise ChatAPIError(f"Could not reach Open WebUI at {url}: {exc}") from exc + if response.status_code >= 400: + raise ChatAPIError(f"{method} {path} failed ({response.status_code}): {response.text[:300]}") + return response.json() if response.content else None + + # --- push: Studio connections -> Open WebUI providers ------------------- + def openai_config(self) -> dict[str, Any]: + return self.request("GET", "/openai/config") + + def set_openai_config(self, config: dict[str, Any]) -> dict[str, Any]: + return self.request("POST", "/openai/config/update", json=config) + + def push_connections(self, connections: list[dict[str, Any]]) -> dict[str, int]: + """Make Open WebUI's provider list match these Studio connections. + + Returns how many entries were pushed and how many foreign ones survived. + """ + current = self.openai_config() + planned = plan_openai_config(current, connections) + self.set_openai_config(planned) + return { + "pushed": len(connections), + "kept": len(planned["OPENAI_API_BASE_URLS"]) - len(connections), + } + + # --- pull: Open WebUI knowledge -> Studio ------------------------------- + def knowledge_bases(self) -> list[dict[str, Any]]: + """Every knowledge base this user can read, as plain dicts.""" + payload = self.request("GET", "/api/v1/knowledge/") + return [_knowledge_summary(item) for item in _as_list(payload)] + + def knowledge_base(self, knowledge_id: str) -> dict[str, Any]: + """One knowledge base, with the names of the files in it.""" + item = self.request("GET", f"/api/v1/knowledge/{knowledge_id}") + summary = _knowledge_summary(item) + summary["files"] = [ + { + "id": file.get("id"), + "name": (file.get("meta") or {}).get("name") or file.get("filename") or "", + } + for file in (item.get("files") or []) + ] + return summary + + +# --- pure helpers (no I/O, so they are cheap to test) ---------------------- +def connection_payload(conn) -> dict[str, Any]: + """The part of a Studio ModelConnection that Open WebUI needs.""" + from model_registry.services import connection_api_key + + return { + "id": conn.id, + "name": conn.name, + "base_url": (conn.base_url or "").strip().rstrip("/"), + "api_key": connection_api_key(conn), + "enabled": conn.enabled, + } + + +def plan_openai_config(current: dict[str, Any], connections: list[dict[str, Any]]) -> dict[str, Any]: + """Merge Studio's connections into Open WebUI's OpenAI provider config. + + Entries Studio pushed before (they carry ``STUDIO_MARKER``) are replaced; + entries somebody added in Open WebUI itself are kept, in their order, with + their config re-keyed to their new index. + """ + urls = list(current.get("OPENAI_API_BASE_URLS") or []) + keys = list(current.get("OPENAI_API_KEYS") or []) + configs = dict(current.get("OPENAI_API_CONFIGS") or {}) + keys += [""] * (len(urls) - len(keys)) + + kept = [ + (url, keys[index], configs.get(str(index), {})) + for index, url in enumerate(urls) + if STUDIO_MARKER not in configs.get(str(index), {}) + ] + ours = [ + ( + connection["base_url"], + connection.get("api_key", ""), + { + STUDIO_MARKER: connection["id"], + "enable": bool(connection.get("enabled", True)), + # Shown in Open WebUI's admin UI, so it reads as the Studio name. + "name": connection["name"], + }, + ) + for connection in connections + ] + + merged = kept + ours + return { + "ENABLE_OPENAI_API": True, + "OPENAI_API_BASE_URLS": [url for url, _, _ in merged], + "OPENAI_API_KEYS": [key for _, key, _ in merged], + "OPENAI_API_CONFIGS": {str(index): config for index, (_, _, config) in enumerate(merged)}, + } + + +def _as_list(payload: Any) -> list[dict[str, Any]]: + """Open WebUI returns either a bare list or {"items": [...]} depending on route.""" + if isinstance(payload, dict): + for field in ("items", "knowledge_bases", "data"): + if isinstance(payload.get(field), list): + return payload[field] + return [] + return payload or [] + + +def _knowledge_summary(item: dict[str, Any]) -> dict[str, Any]: + files = item.get("files") + return { + "id": item.get("id"), + "name": item.get("name") or "", + "description": item.get("description") or "", + "file_count": len(files) if isinstance(files, list) else item.get("file_count"), + "updated_at": item.get("updated_at"), + } diff --git a/chat/apps.py b/chat/apps.py new file mode 100644 index 00000000..cef3a9e1 --- /dev/null +++ b/chat/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class ChatConfig(AppConfig): + """The optional Open WebUI module (see chat/config.py).""" + + name = "chat" + verbose_name = "Chat (Open WebUI)" diff --git a/infra/chat.py b/chat/config.py similarity index 72% rename from infra/chat.py rename to chat/config.py index 0d0647d7..80b627a8 100644 --- a/infra/chat.py +++ b/chat/config.py @@ -1,4 +1,6 @@ -"""Optional Open WebUI chat module. Disabled unless SIMPLEAUDIT_CHAT is set. +"""What the chat module is configured to do, and who Studio says you are. + +Disabled unless SIMPLEAUDIT_CHAT is set. Open WebUI serves from the root of an origin only — it has no base-path/sub-path setting, and its HTML references ``/static``, ``/api`` and ``/ws`` absolutely. So @@ -22,7 +24,7 @@ network with no published port (docker mode). Modes (``SIMPLEAUDIT_CHAT``): - embedded the CLI starts Open WebUI and the proxy in infra.chat_proxy + embedded the CLI starts Open WebUI and the proxy in chat.proxy docker an external proxy (Caddy) does forward-auth; Studio only serves the iframe page and /chat/authz off the URLs 404 and nothing starts — also "disabled", "false", "no", @@ -32,9 +34,6 @@ import os -from django.http import Http404, HttpResponse -from django.views.generic import TemplateView - # --- Configuration --------------------------------------------------------- #: Spellings of "no chat", so nobody has to guess which one this reads. DISABLED_VALUES = frozenset({"", "off", "disabled", "disable", "false", "no", "none", "0"}) @@ -79,39 +78,3 @@ def identity(user) -> dict[str, str]: NAME_HEADER: user.get_full_name() or user.username, ROLE_HEADER: "admin" if is_admin else "user", } - - -def authz(request): - """Forward-auth endpoint: who is this browser? - - 200 with the trusted headers when signed in, 401 otherwise. The proxy copies - the headers onto the upstream request and turns a 401 into a redirect to - Studio's login page. - """ - if not ENABLED: - raise Http404 - user = request.user - if not user.is_authenticated: - return HttpResponse(status=401) - response = HttpResponse(status=200) - for header, value in identity(user).items(): - response[header] = value - return response - - -class ChatView(TemplateView): - """The Studio page that embeds Open WebUI.""" - - template_name = "chat.html" - - def get(self, request, *args, **kwargs): - if not ENABLED: - raise Http404 - if not request.user.is_authenticated: - from django.shortcuts import redirect - - return redirect(f"/login/?next={request.path}") - return super().get(request, *args, **kwargs) - - def get_context_data(self, **kwargs): - return super().get_context_data(chat_url=PUBLIC_URL, **kwargs) diff --git a/chat/management/__init__.py b/chat/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/chat/management/commands/__init__.py b/chat/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/chat/management/commands/chat_knowledge.py b/chat/management/commands/chat_knowledge.py new file mode 100644 index 00000000..6f4a6e95 --- /dev/null +++ b/chat/management/commands/chat_knowledge.py @@ -0,0 +1,59 @@ +"""List the Open WebUI knowledge bases Studio can read, or the files in one. + + python manage.py chat_knowledge # every knowledge base + python manage.py chat_knowledge --id # one, with its files + +This is the consuming direction: what Open WebUI holds, in plain dicts Studio +code can build on (see chat/api.py). +""" +from __future__ import annotations + +from django.core.management.base import BaseCommand, CommandError + +from chat import config as chat +from chat.api import ChatAPI, ChatAPIError + + +class Command(BaseCommand): + help = "List Open WebUI knowledge bases." + + def add_arguments(self, parser): + parser.add_argument("--id", help="Show one knowledge base and the files in it") + parser.add_argument("--as-user", help="Username to read as; default is a superuser") + + def handle(self, *args, **options): + if not chat.ENABLED: + raise CommandError("Chat is disabled. Set SIMPLEAUDIT_CHAT=embedded or docker.") + + api = ChatAPI.as_user(_user(options["as_user"])) + try: + if options["id"]: + base = api.knowledge_base(options["id"]) + self.stdout.write(f"{base['name']} — {base['description']}") + for file in base["files"]: + self.stdout.write(f" {file['name']} ({file['id']})") + return + bases = api.knowledge_bases() + except ChatAPIError as exc: + raise CommandError(str(exc)) from exc + + if not bases: + self.stdout.write(self.style.WARNING("No knowledge bases (or none shared with this user).")) + return + for base in bases: + files = "" if base["file_count"] is None else f" {base['file_count']} file(s)" + self.stdout.write(f"{base['id']} {base['name']}{files}") + + +def _user(username: str | None): + from accounts.models import User + + if username: + user = User.objects.filter(username=username).first() + if user is None: + raise CommandError(f"No user named '{username}'.") + return user + user = User.objects.filter(is_superuser=True).order_by("id").first() + if user is None: + raise CommandError("No superuser to read as; pass --as-user.") + return user diff --git a/chat/management/commands/sync_chat_models.py b/chat/management/commands/sync_chat_models.py new file mode 100644 index 00000000..19fe178b --- /dev/null +++ b/chat/management/commands/sync_chat_models.py @@ -0,0 +1,65 @@ +"""Push Studio model connections into Open WebUI, so chat offers the same models. + + python manage.py sync_chat_models # every workspace's connections + python manage.py sync_chat_models --project demo # one workspace + python manage.py sync_chat_models --dry-run # show what would be pushed + +Connections Studio pushed before are replaced; providers added inside Open WebUI +are left alone (see chat/api.py). +""" +from __future__ import annotations + +from django.core.management.base import BaseCommand, CommandError + +from chat import config as chat +from chat.api import ChatAPI, ChatAPIError, connection_payload + + +class Command(BaseCommand): + help = "Push Studio model connections into Open WebUI." + + def add_arguments(self, parser): + parser.add_argument("--project", help="Workspace slug; default is every workspace") + parser.add_argument("--dry-run", action="store_true", help="Only show what would be pushed") + + def handle(self, *args, **options): + if not chat.ENABLED: + raise CommandError("Chat is disabled. Set SIMPLEAUDIT_CHAT=embedded or docker.") + + from model_registry.models import ModelConnection + + connections = ModelConnection.objects.filter(enabled=True) + if options["project"]: + connections = connections.filter(project__slug=options["project"]) + if not connections.exists(): + raise CommandError(f"No enabled connections in workspace '{options['project']}'.") + + payloads = [connection_payload(conn) for conn in connections] + payloads = [payload for payload in payloads if payload["base_url"]] + for payload in payloads: + key = "key set" if payload["api_key"] else "no key" + self.stdout.write(f" {payload['name']} {payload['base_url']} ({key})") + if not payloads: + self.stdout.write(self.style.WARNING("No connections with a base URL to push.")) + return + if options["dry_run"]: + self.stdout.write(f"Would push {len(payloads)} connection(s).") + return + + try: + result = ChatAPI.as_user(_admin()).push_connections(payloads) + except ChatAPIError as exc: + raise CommandError(str(exc)) from exc + self.stdout.write(self.style.SUCCESS( + f"Pushed {result['pushed']} connection(s); kept {result['kept']} added in Open WebUI." + )) + + +def _admin(): + """A Studio user Open WebUI will treat as an admin — provider config needs one.""" + from accounts.models import User + + user = User.objects.filter(is_superuser=True).order_by("id").first() + if user is None: + raise CommandError("No superuser to act as; Open WebUI's provider config needs an admin.") + return user diff --git a/infra/chat_proxy.py b/chat/proxy.py similarity index 99% rename from infra/chat_proxy.py rename to chat/proxy.py index 5ecb5d3b..f9343ec8 100644 --- a/infra/chat_proxy.py +++ b/chat/proxy.py @@ -28,7 +28,7 @@ import httpx -from infra import chat +from chat import config as chat logger = logging.getLogger(__name__) diff --git a/templates/chat.html b/chat/templates/chat/chat.html similarity index 98% rename from templates/chat.html rename to chat/templates/chat/chat.html index 39c77116..4fcd4e8a 100644 --- a/templates/chat.html +++ b/chat/templates/chat/chat.html @@ -6,7 +6,7 @@ Open WebUI runs on its own origin (it cannot be served under a sub-path), so it is embedded here. Sign-in is automatic: the proxy in front of it identifies the -browser through /chat/authz. See infra/chat.py. +browser through /chat/authz. See chat/config.py. {% endcomment %} diff --git a/chat/tests/__init__.py b/chat/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/chat/tests/test_api.py b/chat/tests/test_api.py new file mode 100644 index 00000000..015ad0b4 --- /dev/null +++ b/chat/tests/test_api.py @@ -0,0 +1,185 @@ +"""Talking to Open WebUI: the merge rules, and a round trip against a stub. + +Run: + SIMPLEAUDIT_LOCAL_SQLITE=1 uv run manage.py test chat.tests.test_api +""" +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from django.test import SimpleTestCase, TestCase + +from accounts.models import ProjectMembership +from chat.api import STUDIO_MARKER, ChatAPI, ChatAPIError, plan_openai_config +from infra.tests.factories import MembershipFactory, ProjectFactory, UserFactory + + +class PlanOpenAIConfigTests(SimpleTestCase): + """The merge that keeps hand-added providers and replaces Studio's own.""" + + def test_pushes_connections_into_an_empty_config(self): + planned = plan_openai_config({}, [ + {"id": 7, "name": "OpenAI", "base_url": "https://api.openai.com/v1", + "api_key": "sk-x", "enabled": True}, + ]) + self.assertEqual(planned["OPENAI_API_BASE_URLS"], ["https://api.openai.com/v1"]) + self.assertEqual(planned["OPENAI_API_KEYS"], ["sk-x"]) + self.assertEqual(planned["OPENAI_API_CONFIGS"]["0"][STUDIO_MARKER], 7) + self.assertIs(planned["ENABLE_OPENAI_API"], True) + + def test_keeps_providers_added_in_open_webui(self): + current = { + "OPENAI_API_BASE_URLS": ["https://theirs.example/v1"], + "OPENAI_API_KEYS": ["theirs"], + "OPENAI_API_CONFIGS": {"0": {"enable": True}}, + } + planned = plan_openai_config(current, [ + {"id": 1, "name": "Ours", "base_url": "https://ours.example/v1", + "api_key": "ours", "enabled": True}, + ]) + self.assertEqual( + planned["OPENAI_API_BASE_URLS"], + ["https://theirs.example/v1", "https://ours.example/v1"], + ) + self.assertNotIn(STUDIO_MARKER, planned["OPENAI_API_CONFIGS"]["0"]) + self.assertEqual(planned["OPENAI_API_CONFIGS"]["1"][STUDIO_MARKER], 1) + + def test_replaces_what_studio_pushed_before(self): + current = plan_openai_config({}, [ + {"id": 1, "name": "Old", "base_url": "https://old.example/v1", + "api_key": "old", "enabled": True}, + ]) + planned = plan_openai_config(current, [ + {"id": 2, "name": "New", "base_url": "https://new.example/v1", + "api_key": "new", "enabled": True}, + ]) + self.assertEqual(planned["OPENAI_API_BASE_URLS"], ["https://new.example/v1"]) + + def test_dropping_every_connection_leaves_only_foreign_entries(self): + current = { + "OPENAI_API_BASE_URLS": ["https://theirs.example/v1", "https://ours.example/v1"], + "OPENAI_API_KEYS": ["theirs", "ours"], + "OPENAI_API_CONFIGS": {"0": {}, "1": {STUDIO_MARKER: 4}}, + } + planned = plan_openai_config(current, []) + self.assertEqual(planned["OPENAI_API_BASE_URLS"], ["https://theirs.example/v1"]) + self.assertEqual(planned["OPENAI_API_KEYS"], ["theirs"]) + + def test_shorter_key_list_does_not_lose_urls(self): + current = { + "OPENAI_API_BASE_URLS": ["https://a.example/v1", "https://b.example/v1"], + "OPENAI_API_KEYS": ["only-one"], + "OPENAI_API_CONFIGS": {}, + } + planned = plan_openai_config(current, []) + self.assertEqual(len(planned["OPENAI_API_BASE_URLS"]), 2) + self.assertEqual(planned["OPENAI_API_KEYS"], ["only-one", ""]) + + +class _StubOpenWebUI(BaseHTTPRequestHandler): + """Just enough Open WebUI to answer sign-in, config and knowledge.""" + + protocol_version = "HTTP/1.1" + state: dict = {} + + def log_message(self, *args): + pass + + def _reply(self, status, payload): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = json.loads(self.rfile.read(length) or b"{}") + if self.path == "/api/v1/auths/signin": + email = self.headers.get("X-Studio-Email") + if not email: + return self._reply(400, {"detail": "no trusted header"}) + self.state["signed_in_as"] = email + self.state["role"] = self.headers.get("X-Studio-Role") + return self._reply(200, {"token": "t0ken", "email": email}) + if self.path == "/openai/config/update": + if self.headers.get("Authorization") != "Bearer t0ken": + return self._reply(401, {"detail": "no token"}) + self.state["config"] = body + return self._reply(200, body) + return self._reply(404, {"detail": "nope"}) + + def do_GET(self): + if self.headers.get("Authorization") != "Bearer t0ken": + return self._reply(401, {"detail": "no token"}) + if self.path == "/openai/config": + return self._reply(200, self.state.get("config", {})) + if self.path == "/api/v1/knowledge/": + return self._reply(200, [ + {"id": "kb1", "name": "Policies", "description": "HR", "files": [{"id": "f1"}]}, + ]) + if self.path == "/api/v1/knowledge/kb1": + return self._reply(200, { + "id": "kb1", "name": "Policies", "description": "HR", + "files": [{"id": "f1", "meta": {"name": "handbook.pdf"}}], + }) + return self._reply(404, {"detail": "nope"}) + + +class ChatAPITests(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + _StubOpenWebUI.state = {} + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), _StubOpenWebUI) + cls.server.daemon_threads = True + threading.Thread(target=cls.server.serve_forever, daemon=True).start() + cls.url = f"http://127.0.0.1:{cls.server.server_address[1]}" + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + super().tearDownClass() + + def _api(self, **user_kwargs): + user = UserFactory(**user_kwargs) + MembershipFactory(user=user, project=ProjectFactory(), role=ProjectMembership.Role.ADMIN) + from chat.config import identity + + return ChatAPI(identity(user), base_url=self.url) + + def test_signs_in_with_the_trusted_headers(self): + api = self._api(username="pusher") + api.sign_in() + self.assertEqual(_StubOpenWebUI.state["signed_in_as"], "pusher@test.com") + self.assertEqual(_StubOpenWebUI.state["role"], "admin") + + def test_push_connections_reports_what_it_did(self): + api = self._api(username="pusher2") + result = api.push_connections([ + {"id": 1, "name": "OpenAI", "base_url": "https://api.openai.com/v1", + "api_key": "sk-x", "enabled": True}, + ]) + self.assertEqual(result, {"pushed": 1, "kept": 0}) + self.assertEqual( + _StubOpenWebUI.state["config"]["OPENAI_API_BASE_URLS"], + ["https://api.openai.com/v1"], + ) + + def test_knowledge_bases_are_normalised(self): + bases = self._api(username="reader").knowledge_bases() + self.assertEqual(bases, [{ + "id": "kb1", "name": "Policies", "description": "HR", + "file_count": 1, "updated_at": None, + }]) + + def test_knowledge_base_lists_file_names(self): + base = self._api(username="reader2").knowledge_base("kb1") + self.assertEqual(base["files"], [{"id": "f1", "name": "handbook.pdf"}]) + + def test_an_unreachable_open_webui_is_reported_clearly(self): + api = ChatAPI({"X-Studio-Email": "x@y.z"}, base_url="http://127.0.0.1:1") + with self.assertRaises(ChatAPIError) as caught: + api.knowledge_bases() + self.assertIn("Could not reach Open WebUI", str(caught.exception)) diff --git a/infra/tests/test_chat.py b/chat/tests/test_chat.py similarity index 92% rename from infra/tests/test_chat.py rename to chat/tests/test_chat.py index ffd4290c..c32678d3 100644 --- a/infra/tests/test_chat.py +++ b/chat/tests/test_chat.py @@ -1,14 +1,14 @@ """The optional Open WebUI module: off by default, forward-auth when on. Run: - SIMPLEAUDIT_LOCAL_SQLITE=1 uv run manage.py test infra.tests.test_chat + SIMPLEAUDIT_LOCAL_SQLITE=1 uv run manage.py test chat """ from unittest.mock import patch from django.test import Client, TestCase from accounts.models import ProjectMembership -from infra.chat import EMAIL_HEADER, NAME_HEADER, ROLE_HEADER, is_disabled +from chat.config import EMAIL_HEADER, NAME_HEADER, ROLE_HEADER, is_disabled from infra.tests.factories import MembershipFactory, ProjectFactory, UserFactory @@ -39,7 +39,7 @@ def test_no_sidebar_entry_when_off(self): self.assertNotContains(client.get("/"), 'href="/chat/"') -@patch("infra.chat.ENABLED", True) +@patch("chat.config.ENABLED", True) class ChatEnabledTests(TestCase): def setUp(self): self.project = ProjectFactory() @@ -76,7 +76,7 @@ def test_user_without_email_still_gets_one(self): def test_page_embeds_the_chat_origin(self): self._sign_in(username="viewer") - with patch("infra.chat.PUBLIC_URL", "http://localhost:8801"): + with patch("chat.config.PUBLIC_URL", "http://localhost:8801"): response = self.client.get("/chat/") self.assertContains(response, 'src="http://localhost:8801"') diff --git a/chat/urls.py b/chat/urls.py new file mode 100644 index 00000000..3247476a --- /dev/null +++ b/chat/urls.py @@ -0,0 +1,13 @@ +"""Chat URLs, mounted at /chat/ by config/urls.py. + +Both views 404 while the module is disabled, so they are safe to include +unconditionally. +""" +from django.urls import path + +from chat.views import ChatView, authz + +urlpatterns = [ + path("", ChatView.as_view(), name="chat"), + path("authz", authz, name="chat_authz"), +] diff --git a/chat/views.py b/chat/views.py new file mode 100644 index 00000000..20f48f46 --- /dev/null +++ b/chat/views.py @@ -0,0 +1,47 @@ +"""The two things Studio serves for chat: the page, and who the browser is. + +Everything about *why* it works this way is in chat/config.py. +""" +from __future__ import annotations + +from django.http import Http404, HttpResponse +from django.shortcuts import redirect +from django.views.generic import TemplateView + +# The module, not the names: tests and runtime both read ENABLED/PUBLIC_URL as +# they are now, not as they were at import time. +from chat import config + + +def authz(request): + """Forward-auth endpoint: who is this browser? + + 200 with the trusted headers when signed in, 401 otherwise. The proxy copies + the headers onto the upstream request and turns a 401 into a redirect to + Studio's login page. + """ + if not config.ENABLED: + raise Http404 + user = request.user + if not user.is_authenticated: + return HttpResponse(status=401) + response = HttpResponse(status=200) + for header, value in config.identity(user).items(): + response[header] = value + return response + + +class ChatView(TemplateView): + """The Studio page that embeds Open WebUI.""" + + template_name = "chat/chat.html" + + def get(self, request, *args, **kwargs): + if not config.ENABLED: + raise Http404 + if not request.user.is_authenticated: + return redirect(f"/login/?next={request.path}") + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + return super().get_context_data(chat_url=config.PUBLIC_URL, **kwargs) diff --git a/config/settings.py b/config/settings.py index 8440b419..b8359498 100644 --- a/config/settings.py +++ b/config/settings.py @@ -120,6 +120,10 @@ def _csrf_trusted_origins() -> list[str]: "judges", "audits", "infra", + # Optional module: its URLs 404 and nothing runs unless SIMPLEAUDIT_CHAT is + # set (chat/config.py). Installed either way so its templates, management + # commands and tests resolve. + "chat", ] MIDDLEWARE = [ diff --git a/config/urls.py b/config/urls.py index 3e4884b1..00db00e3 100644 --- a/config/urls.py +++ b/config/urls.py @@ -10,7 +10,6 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from accounts.views import healthz, readyz -from infra import chat as _chat from infra.health_api import health_panel_api from infra.runs_table import PreferenceView, RunsBulkView, RunsDataView, RunsExportView from infra.seo import LandingView, llms_txt, robots_txt, sitemap_xml @@ -204,11 +203,8 @@ def home_view(request, *args, **kwargs): path("me/preferences/", PreferenceView.as_view(), name="preferences"), ] -# Optional Open WebUI module. The chat UI itself lives on its own origin; these -# are the iframe page and the forward-auth endpoint its proxy calls. Both 404 -# unless SIMPLEAUDIT_CHAT is set. See infra/chat.py. -urlpatterns += [ - path("chat/", _chat.ChatView.as_view(), name="chat"), - path("chat/authz", _chat.authz, name="chat_authz"), -] +# Optional Open WebUI module (the `chat` app). The chat UI itself lives on its +# own origin; these routes are the iframe page and the forward-auth endpoint its +# proxy calls. Both 404 unless SIMPLEAUDIT_CHAT is set. See chat/config.py. +urlpatterns += [path("chat/", include("chat.urls"))] diff --git a/docs/chat.md b/docs/chat.md index 37949d10..0c003366 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -1,7 +1,19 @@ # Chat (Open WebUI) A module that embeds [Open WebUI](https://openwebui.com) in Studio at `/chat/`, -signed in as the Studio user. It is part of the bundle the local one-liner starts +signed in as the Studio user. It lives in one Django app, `chat/`: + +``` +chat/ + config.py what the module is configured to do, and who you are + views.py urls.py the iframe page and /chat/authz + proxy.py the forward-auth proxy + Open WebUI's lifecycle (embedded) + api.py talking to Open WebUI's API, both directions + management/ sync_chat_models, chat_knowledge + templates/ tests/ +``` + +It is part of the bundle the local one-liner starts and of the Compose deployment `.env.example` describes, and it can be left out entirely: with `SIMPLEAUDIT_CHAT` set to `off` (or `disabled`, `false`, `no`, `0`) or unset in a deployment that does not set it, `/chat/` and `/chat/authz` @@ -127,11 +139,55 @@ cookie. Different registrable domains will not work. | `SIMPLEAUDIT_STUDIO_URL` | `http://localhost:8000` | where signed-out users are sent (docker) | | `SIMPLEAUDIT_CHAT_CMD` | auto | command that starts Open WebUI | +## Syncing with Studio (scaffolding) + +`chat/api.py` talks to Open WebUI's API in both directions. It authenticates the +same way the proxy makes the browser authenticate — POST the trusted identity +headers to `/api/v1/auths/signin`, use the token that comes back — so there is no +API key to provision and every call runs as a real Open WebUI user with that +user's role. + +**Push — Studio model connections become Open WebUI providers.** A Studio +connection is a base URL plus a key, which is exactly Open WebUI's +OpenAI-compatible provider config (`OPENAI_API_BASE_URLS` / `OPENAI_API_KEYS` / +`OPENAI_API_CONFIGS`): + +```bash +python manage.py sync_chat_models --dry-run # show what would be pushed +python manage.py sync_chat_models # push every enabled connection +python manage.py sync_chat_models --project demo +``` + +Those lists are also editable by hand in Open WebUI, so each pushed entry carries +a `simpleaudit_connection_id` marker in its config. A sync replaces the marked +entries and leaves everything else where it is — the command says how many of +each. Pushing provider config needs an Open WebUI admin, so the command acts as a +Studio superuser. + +**Pull — what Open WebUI holds.** Knowledge bases come back as plain dicts, so +Studio code never sees Open WebUI's schema: + +```bash +python manage.py chat_knowledge # id, name, file count +python manage.py chat_knowledge --id # one, with its file names +``` + +```python +from chat.api import ChatAPI + +bases = ChatAPI.as_user(request.user).knowledge_bases() +``` + +Both directions are deliberately explicit for now: nothing syncs on save, and +nothing is scheduled. Wiring a signal or a monitor onto `push_connections` is the +next step when the shape has settled. + ## Removing it -Set `SIMPLEAUDIT_CHAT=disabled`, or pass `--disable-chat` to the CLI. To drop the code, delete `infra/chat.py`, -`infra/chat_proxy.py`, `infra/tests/test_chat.py`, `templates/chat.html`, -`deploy/compose/Caddyfile.chat`, the two `chat/` routes in `config/urls.py`, the -Chat entry in `infra/context_processors.py`, the `--chat` flag in -`simpleaudit_studio/cli.py` and the `chat` profile in `docker-compose.yml`. -Nothing else refers to it. +Set `SIMPLEAUDIT_CHAT=disabled`, or pass `--disable-chat` to the CLI. + +To drop the code, delete the `chat/` app and `deploy/compose/Caddyfile.chat`, +then remove its four references: `"chat"` in `INSTALLED_APPS`, the `chat/` route +in `config/urls.py`, the Chat entry in `infra/context_processors.py`, the +`--chat` flag in `simpleaudit_studio/cli.py`, and the `chat` profile in +`docker-compose.yml`. Nothing else refers to it. diff --git a/infra/context_processors.py b/infra/context_processors.py index b186bf88..7c60fd6b 100644 --- a/infra/context_processors.py +++ b/infra/context_processors.py @@ -107,7 +107,7 @@ def nav(request): if user is None or not user.is_authenticated: return {} from accounts.services import is_any_project_admin - from infra.chat import ENABLED as chat_enabled + from chat.config import ENABLED as chat_enabled admin = is_any_project_admin(user) path = request.path diff --git a/pyproject.toml b/pyproject.toml index d6e68a72..5c8e5079 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ packages = [ "config", "accounts", "audits", + "chat", "infra", "scenarios", "model_registry", diff --git a/simpleaudit_studio/cli.py b/simpleaudit_studio/cli.py index c8ed52c3..3e4f4248 100644 --- a/simpleaudit_studio/cli.py +++ b/simpleaudit_studio/cli.py @@ -133,10 +133,10 @@ def main() -> None: # --- Chat: Open WebUI + its forward-auth proxy --- chat_process = None - from infra import chat as chat_config + from chat import config as chat_config if chat_config.ENABLED: - from infra import chat_proxy + from chat import proxy as chat_proxy print("💬 Starting chat (Open WebUI)...") if chat_proxy.is_first_run(): @@ -205,7 +205,7 @@ def _sigterm_handler(signum, frame): stop_embedded_hatchet() finally: if chat_process is not None: - from infra.chat_proxy import stop_open_webui + from chat.proxy import stop_open_webui stop_open_webui() if mock_server is not None: From 0a69828431f2224be20b8708c23adba68bbd912d Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 18:23:22 +0200 Subject: [PATCH 13/65] feat: push model connections to chat automatically, and quieten the browser console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connections now reach Open WebUI on their own: chat/signals.py follows ModelConnection and RegisteredModel, and the CLI syncs once as soon as chat answers, which covers whatever changed while it was off. The management command stays for a manual run and for --dry-run, and now shares the same code path. The push deliberately stays off the request's path — on_commit so a rolled-back row is never pushed, in a background thread so a save does not wait on a second service, debounced so an edit that writes a connection and its models is one push, and best-effort so a chat that is down is logged and forgotten. Studio's data is the source of truth; the next push catches up. A connection's registered models are pushed as that provider's model_ids, so chat offers what Studio registered rather than everything the provider lists. No registered models means no restriction. Two things the browser console complained about: - WebSocket upgrades were answered with 501, so Socket.IO retried and fell back to polling. The proxy now tunnels them: forward the handshake with the identity headers, and once Open WebUI answers 101, pipe the two sockets together. - Open WebUI polled Ollama on every page load (a 500 each time) and showed an empty Ollama section in its settings. Nothing in a Studio deployment serves Ollama, so it is off in the environment for a fresh instance and turned off through the API on every sync for one that already had it on. Verified against the running instance: a rename reached chat within the debounce window, a real handshake through the proxy returned 101 followed by socket.io's OPEN frame, and ENABLE_OLLAMA_API is now false. --- chat/api.py | 31 ++++- chat/apps.py | 7 + chat/management/commands/sync_chat_models.py | 27 ++-- chat/proxy.py | 81 +++++++++++- chat/signals.py | 31 +++++ chat/sync.py | 91 +++++++++++++ chat/tests/test_sync.py | 129 +++++++++++++++++++ docker-compose.yml | 2 + docs/chat.md | 31 ++++- simpleaudit_studio/cli.py | 20 ++- 10 files changed, 417 insertions(+), 33 deletions(-) create mode 100644 chat/signals.py create mode 100644 chat/sync.py create mode 100644 chat/tests/test_sync.py diff --git a/chat/api.py b/chat/api.py index ad1c0fb1..36bcfe44 100644 --- a/chat/api.py +++ b/chat/api.py @@ -110,6 +110,24 @@ def push_connections(self, connections: list[dict[str, Any]]) -> dict[str, int]: "kept": len(planned["OPENAI_API_BASE_URLS"]) - len(connections), } + def disable_ollama(self) -> None: + """Turn Ollama off in Open WebUI's settings. + + Nothing in a Studio deployment serves Ollama, but Open WebUI polls it on + every page load — a 500 in the browser console each time — and shows an + empty Ollama section in the connection settings. The environment variable + only seeds the first start, so an instance that already has it on has to + be told. + """ + current = self.request("GET", "/ollama/config") + if current.get("ENABLE_OLLAMA_API") is False: + return + self.request("POST", "/ollama/config/update", json={ + "ENABLE_OLLAMA_API": False, + "OLLAMA_BASE_URLS": current.get("OLLAMA_BASE_URLS") or [], + "OLLAMA_API_CONFIGS": current.get("OLLAMA_API_CONFIGS") or {}, + }) + # --- pull: Open WebUI knowledge -> Studio ------------------------------- def knowledge_bases(self) -> list[dict[str, Any]]: """Every knowledge base this user can read, as plain dicts.""" @@ -132,7 +150,12 @@ def knowledge_base(self, knowledge_id: str) -> dict[str, Any]: # --- pure helpers (no I/O, so they are cheap to test) ---------------------- def connection_payload(conn) -> dict[str, Any]: - """The part of a Studio ModelConnection that Open WebUI needs.""" + """The part of a Studio ModelConnection that Open WebUI needs. + + ``model_ids`` narrows the connection to the models Studio has registered + under it; empty means Studio has registered none, and Open WebUI then offers + whatever the provider lists. + """ from model_registry.services import connection_api_key return { @@ -141,6 +164,10 @@ def connection_payload(conn) -> dict[str, Any]: "base_url": (conn.base_url or "").strip().rstrip("/"), "api_key": connection_api_key(conn), "enabled": conn.enabled, + "project_slug": conn.project.slug, + "model_ids": sorted( + conn.models.filter(enabled=True).values_list("model_id", flat=True).distinct() + ), } @@ -170,6 +197,8 @@ def plan_openai_config(current: dict[str, Any], connections: list[dict[str, Any] "enable": bool(connection.get("enabled", True)), # Shown in Open WebUI's admin UI, so it reads as the Studio name. "name": connection["name"], + # Open WebUI treats an empty list as "no restriction". + "model_ids": list(connection.get("model_ids") or []), }, ) for connection in connections diff --git a/chat/apps.py b/chat/apps.py index cef3a9e1..fc59f6ff 100644 --- a/chat/apps.py +++ b/chat/apps.py @@ -6,3 +6,10 @@ class ChatConfig(AppConfig): name = "chat" verbose_name = "Chat (Open WebUI)" + + def ready(self): + """Follow model-connection changes, but only when chat is switched on.""" + from chat import config + + if config.ENABLED: + from chat import signals # noqa: F401 (registers the receivers) diff --git a/chat/management/commands/sync_chat_models.py b/chat/management/commands/sync_chat_models.py index 19fe178b..a5f3fd17 100644 --- a/chat/management/commands/sync_chat_models.py +++ b/chat/management/commands/sync_chat_models.py @@ -12,7 +12,8 @@ from django.core.management.base import BaseCommand, CommandError from chat import config as chat -from chat.api import ChatAPI, ChatAPIError, connection_payload +from chat import sync +from chat.api import ChatAPIError class Command(BaseCommand): @@ -26,16 +27,11 @@ def handle(self, *args, **options): if not chat.ENABLED: raise CommandError("Chat is disabled. Set SIMPLEAUDIT_CHAT=embedded or docker.") - from model_registry.models import ModelConnection - - connections = ModelConnection.objects.filter(enabled=True) + payloads = sync.connections_to_push() if options["project"]: - connections = connections.filter(project__slug=options["project"]) - if not connections.exists(): + payloads = [p for p in payloads if p["project_slug"] == options["project"]] + if not payloads: raise CommandError(f"No enabled connections in workspace '{options['project']}'.") - - payloads = [connection_payload(conn) for conn in connections] - payloads = [payload for payload in payloads if payload["base_url"]] for payload in payloads: key = "key set" if payload["api_key"] else "no key" self.stdout.write(f" {payload['name']} {payload['base_url']} ({key})") @@ -47,19 +43,12 @@ def handle(self, *args, **options): return try: - result = ChatAPI.as_user(_admin()).push_connections(payloads) + # One code path with the signals, so a manual sync and an automatic + # one leave Open WebUI in the same state. + result = sync.push_now(payloads) except ChatAPIError as exc: raise CommandError(str(exc)) from exc self.stdout.write(self.style.SUCCESS( f"Pushed {result['pushed']} connection(s); kept {result['kept']} added in Open WebUI." )) - -def _admin(): - """A Studio user Open WebUI will treat as an admin — provider config needs one.""" - from accounts.models import User - - user = User.objects.filter(is_superuser=True).order_by("id").first() - if user is None: - raise CommandError("No superuser to act as; Open WebUI's provider config needs an admin.") - return user diff --git a/chat/proxy.py b/chat/proxy.py index f9343ec8..abb0929e 100644 --- a/chat/proxy.py +++ b/chat/proxy.py @@ -8,9 +8,10 @@ 2. ask Studio ``GET /chat/authz`` who the browser is, forwarding its cookies, 3. forward the request to Open WebUI with the returned headers added. -WebSockets are not proxied: an ``Upgrade`` request gets 501, which makes Open -WebUI's Socket.IO client stay on its HTTP long-polling transport. Chat responses -stream over plain HTTP (SSE) and are unaffected. +WebSocket upgrades are tunnelled: the handshake is forwarded with the identity +headers attached, and once the upstream answers 101 the two sockets are simply +piped together. Open WebUI's Socket.IO then behaves as it does behind Caddy, +rather than falling back to long-polling and logging failed upgrades. """ from __future__ import annotations @@ -19,6 +20,7 @@ import os import shutil import signal +import socket import subprocess import threading import time @@ -59,10 +61,6 @@ def log_message(self, fmt, *args): logger.debug("chat-proxy %s", fmt % args) def _proxy(self): - if self.headers.get("Upgrade", "").lower() == "websocket": - self.send_error(501, "WebSocket not proxied") - return - headers = {k: v for k, v in self.headers.items() if k.lower() not in _STRIP_FROM_REQUEST} # The body is forwarded byte for byte, so the upstream may only use an # encoding this client asked for. Without this httpx adds its own @@ -80,6 +78,10 @@ def _proxy(self): return headers.update(identity) + if self.headers.get("Upgrade", "").lower() == "websocket": + self._tunnel(identity) + return + length = int(self.headers.get("Content-Length") or 0) body = self.rfile.read(length) if length else None @@ -105,6 +107,51 @@ def _proxy(self): except (BrokenPipeError, ConnectionResetError): pass # the browser navigated away mid-stream + def _tunnel(self, identity: dict[str, str]) -> None: + """Hand a WebSocket handshake to Open WebUI and then get out of the way. + + Nothing here understands WebSocket framing: once the upstream has agreed + to the upgrade, the two sockets carry bytes in both directions until one + of them closes. The identity headers go on the handshake, which is the + only part Open WebUI authenticates. + """ + upstream_url = urlsplit(chat.UPSTREAM) + host, port = upstream_url.hostname or "127.0.0.1", upstream_url.port or 80 + try: + upstream = socket.create_connection((host, port), timeout=10) + except OSError as exc: + logger.warning("chat websocket upstream unreachable: %s", exc) + self.send_error(502, "Chat backend unavailable") + return + + # The handshake keeps the hop-by-hop headers this time (Connection, + # Upgrade and the Sec-WebSocket-* set are the handshake), minus any + # identity the client tried to supply. + forwarded = { + key: value for key, value in self.headers.items() + if key.lower() not in {h.lower() for h in chat.TRUSTED_HEADERS} | {"host"} + } + forwarded["Host"] = f"{host}:{port}" + forwarded.update(identity) + request = f"GET {self.path} HTTP/1.1\r\n" + "".join( + f"{key}: {value}\r\n" for key, value in forwarded.items() + ) + "\r\n" + + self.close_connection = True + client = self.connection + try: + upstream.sendall(request.encode("latin-1")) + upstream.settimeout(None) + client.settimeout(None) + pump = threading.Thread(target=_pipe, args=(client, upstream), daemon=True) + pump.start() + _pipe(upstream, client) + pump.join(timeout=1) + except OSError: + pass # either side hung up; nothing to salvage + finally: + upstream.close() + def _identify(self, cookie: str) -> dict[str, str] | None: """Ask Studio who this browser is. None when signed out.""" try: @@ -122,6 +169,23 @@ def _identify(self, cookie: str) -> dict[str, str] | None: do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = _proxy +def _pipe(source: socket.socket, destination: socket.socket) -> None: + """Copy bytes one way until the source closes, then half-close the other end.""" + try: + while True: + chunk = source.recv(65536) + if not chunk: + break + destination.sendall(chunk) + except OSError: + pass + finally: + try: + destination.shutdown(socket.SHUT_WR) + except OSError: + pass + + def serve(studio_port: int) -> ThreadingHTTPServer: """Start the proxy on chat.PROXY_PORT in a daemon thread.""" _Handler.studio_url = f"http://127.0.0.1:{studio_port}" @@ -304,6 +368,9 @@ def _spawn(home: Path) -> subprocess.Popen: "WEBUI_AUTH_TRUSTED_NAME_HEADER": chat.NAME_HEADER, "WEBUI_AUTH_TRUSTED_ROLE_HEADER": chat.ROLE_HEADER, "ENABLE_SIGNUP": "false", + # Nothing here serves Ollama, and Open WebUI polls it on every page load + # (a 500 per poll in the console) and shows an empty section in settings. + "ENABLE_OLLAMA_API": "false", "WEBUI_URL": chat.PUBLIC_URL, } # Open WebUI keeps its signing key in ``.webui_secret_key`` in the working diff --git a/chat/signals.py b/chat/signals.py new file mode 100644 index 00000000..cd555bf1 --- /dev/null +++ b/chat/signals.py @@ -0,0 +1,31 @@ +"""Model connections change in Studio, chat follows. + +Connected from ``ChatConfig.ready()``, and only while the module is enabled. +The work itself is deferred and best-effort — see chat/sync.py. +""" +from __future__ import annotations + +from django.db import transaction +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver + +from chat import sync + + +@receiver(post_save, sender="model_registry.ModelConnection", dispatch_uid="chat.connection_saved") +@receiver(post_delete, sender="model_registry.ModelConnection", dispatch_uid="chat.connection_deleted") +def connection_changed(sender, instance, **kwargs): + _after_commit(f"connection {instance.pk}") + + +@receiver(post_save, sender="model_registry.RegisteredModel", dispatch_uid="chat.model_saved") +@receiver(post_delete, sender="model_registry.RegisteredModel", dispatch_uid="chat.model_deleted") +def registered_model_changed(sender, instance, **kwargs): + # Which models a connection offers is part of what gets pushed, so a model + # appearing or going away matters as much as the connection itself. + _after_commit(f"model {instance.pk}") + + +def _after_commit(reason: str) -> None: + """Push once the change is actually committed (and not at all if it isn't).""" + transaction.on_commit(lambda: sync.schedule_push(reason)) diff --git a/chat/sync.py b/chat/sync.py new file mode 100644 index 00000000..e49f1f26 --- /dev/null +++ b/chat/sync.py @@ -0,0 +1,91 @@ +"""Keeping Open WebUI's providers in step with Studio's model connections. + +``push_now`` does the work; ``schedule_push`` is what the signals in +``chat/signals.py`` call. Pushing is deliberately kept off the request's path: + + * it runs after the transaction commits, so Open WebUI never sees a row that + was rolled back, + * in a background thread, so saving a connection does not wait on a second + service, + * debounced, so editing three connections (or a bulk import) is one push, + * and best-effort: a chat that is down, still starting, or disabled is logged + and forgotten. Studio's own data is the source of truth, and the next push — + or ``manage.py sync_chat_models`` — catches up. +""" +from __future__ import annotations + +import logging +import os +import threading + +from chat import config +from chat.api import ChatAPI, ChatAPIError, connection_payload + +logger = logging.getLogger(__name__) + +#: How long to wait for more changes before pushing. A save is rarely alone: +#: the connections page writes a connection and its models in one go. +DELAY_SECONDS = float(os.environ.get("SIMPLEAUDIT_CHAT_SYNC_DELAY", "2")) + +_timer: threading.Timer | None = None +_timer_lock = threading.Lock() + + +def connections_to_push() -> list[dict]: + """Every enabled connection that has somewhere to point at.""" + from model_registry.models import ModelConnection + + payloads = (connection_payload(conn) for conn in ModelConnection.objects.filter(enabled=True)) + return [payload for payload in payloads if payload["base_url"]] + + +def push_now(payloads: list[dict] | None = None) -> dict[str, int]: + """Push connections (all of them by default). Raises ChatAPIError on refusal.""" + api = ChatAPI.as_user(_admin()) + result = api.push_connections(connections_to_push() if payloads is None else payloads) + # Studio never serves Ollama; leaving it on costs a failing request per page + # load and an empty section in Open WebUI's settings. + api.disable_ollama() + return result + + +def schedule_push(reason: str = "") -> None: + """Push soon, once, in the background. Never raises.""" + if not config.ENABLED: + return + global _timer + with _timer_lock: + if _timer is not None: + _timer.cancel() + _timer = threading.Timer(DELAY_SECONDS, _run, args=(reason,)) + _timer.daemon = True + _timer.start() + + +def _run(reason: str) -> None: + global _timer + with _timer_lock: + _timer = None + try: + result = push_now() + except ChatAPIError as exc: + # Expected while Open WebUI is still starting, or when it is not running + # at all. Not worth a traceback. + logger.info("Chat model sync skipped (%s): %s", reason or "change", exc) + except Exception: + logger.warning("Chat model sync failed (%s)", reason or "change", exc_info=True) + else: + logger.info( + "Chat models synced (%s): pushed %d, kept %d", + reason or "change", result["pushed"], result["kept"], + ) + + +def _admin(): + """A Studio user Open WebUI treats as an admin — provider config needs one.""" + from accounts.models import User + + user = User.objects.filter(is_superuser=True).order_by("id").first() + if user is None: + raise ChatAPIError("No superuser to act as; Open WebUI's provider config needs an admin.") + return user diff --git a/chat/tests/test_sync.py b/chat/tests/test_sync.py new file mode 100644 index 00000000..02130b9e --- /dev/null +++ b/chat/tests/test_sync.py @@ -0,0 +1,129 @@ +"""Connections change in Studio, chat follows — without blocking the save. + +Run: + SIMPLEAUDIT_LOCAL_SQLITE=1 uv run manage.py test chat.tests.test_sync +""" +import time +from unittest.mock import patch + +from django.test import TestCase, TransactionTestCase + +from chat import sync +from chat.api import ChatAPIError +from infra.tests.factories import ( + ModelConnectionFactory, + ProjectFactory, + RegisteredModelFactory, + UserFactory, +) + + +class ConnectionsToPushTests(TestCase): + def setUp(self): + self.project = ProjectFactory() + + def test_only_enabled_connections_with_a_base_url(self): + ModelConnectionFactory(project=self.project, name="on", base_url="https://a.example/v1") + ModelConnectionFactory(project=self.project, name="off", base_url="https://b.example/v1", + enabled=False) + ModelConnectionFactory(project=self.project, name="blank", base_url="") + self.assertEqual([c["name"] for c in sync.connections_to_push()], ["on"]) + + def test_registered_models_narrow_the_connection(self): + conn = ModelConnectionFactory(project=self.project, base_url="https://a.example/v1") + RegisteredModelFactory(connection=conn, project=self.project, model_id="gpt-4o") + RegisteredModelFactory(connection=conn, project=self.project, model_id="gpt-4o-mini") + RegisteredModelFactory(connection=conn, project=self.project, model_id="gone", enabled=False) + self.assertEqual(sync.connections_to_push()[0]["model_ids"], ["gpt-4o", "gpt-4o-mini"]) + + def test_a_connection_without_registered_models_is_not_narrowed(self): + ModelConnectionFactory(project=self.project, base_url="https://a.example/v1") + self.assertEqual(sync.connections_to_push()[0]["model_ids"], []) + + +class SchedulePushTests(TestCase): + """The debounce: many changes, one push, off the caller's thread.""" + + def setUp(self): + self.pushes = [] + patcher = patch.object(sync, "push_now", lambda: self.pushes.append(1) or {"pushed": 1, "kept": 0}) + patcher.start() + self.addCleanup(patcher.stop) + delay = patch.object(sync, "DELAY_SECONDS", 0.05) + delay.start() + self.addCleanup(delay.stop) + + def _settle(self): + time.sleep(0.3) + + @patch("chat.config.ENABLED", True) + def test_a_burst_of_changes_is_one_push(self): + for _ in range(5): + sync.schedule_push("test") + self._settle() + self.assertEqual(len(self.pushes), 1) + + @patch("chat.config.ENABLED", False) + def test_nothing_is_pushed_while_chat_is_disabled(self): + sync.schedule_push("test") + self._settle() + self.assertEqual(self.pushes, []) + + @patch("chat.config.ENABLED", True) + def test_a_chat_that_is_not_answering_does_not_raise(self): + with patch.object(sync, "push_now", side_effect=ChatAPIError("not running")): + sync.schedule_push("test") + self._settle() # the failure is logged, the caller never sees it + + +class SignalTests(TransactionTestCase): + """on_commit means the push waits for the transaction, and skips a rollback. + + Importing chat.signals is what connects the receivers (that is what + ChatConfig.ready does when chat is on), so the test does it explicitly rather + than depending on the environment it runs in. + """ + + def setUp(self): + import chat.signals # noqa: F401 (connects the receivers) + + self.project = ProjectFactory() + self.scheduled = [] + patcher = patch.object(sync, "schedule_push", lambda reason="": self.scheduled.append(reason)) + patcher.start() + self.addCleanup(patcher.stop) + + def test_saving_a_connection_schedules_a_push(self): + conn = ModelConnectionFactory(project=self.project, base_url="https://a.example/v1") + self.assertTrue(any(str(conn.pk) in reason for reason in self.scheduled)) + + def test_deleting_a_connection_schedules_a_push(self): + conn = ModelConnectionFactory(project=self.project, base_url="https://a.example/v1") + self.scheduled.clear() + conn.delete() + self.assertEqual(len(self.scheduled), 1) + + def test_registering_a_model_schedules_a_push(self): + conn = ModelConnectionFactory(project=self.project, base_url="https://a.example/v1") + self.scheduled.clear() + RegisteredModelFactory(connection=conn, project=self.project, model_id="gpt-4o") + self.assertEqual(len(self.scheduled), 1) + + def test_a_rolled_back_change_pushes_nothing(self): + from django.db import transaction + + try: + with transaction.atomic(): + ModelConnectionFactory(project=self.project, base_url="https://a.example/v1") + raise RuntimeError("rolled back") + except RuntimeError: + pass + self.assertEqual(self.scheduled, []) + + +class PushNowTests(TestCase): + def test_without_a_superuser_it_says_so(self): + UserFactory(username="ordinary", is_superuser=False) + with self.assertRaises(ChatAPIError) as caught: + sync.push_now() + self.assertIn("No superuser", str(caught.exception)) diff --git a/docker-compose.yml b/docker-compose.yml index c85b8346..bdcf5e60 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -182,6 +182,8 @@ services: WEBUI_AUTH_TRUSTED_NAME_HEADER: X-Studio-Name WEBUI_AUTH_TRUSTED_ROLE_HEADER: X-Studio-Role ENABLE_SIGNUP: "false" + # Nothing in this stack serves Ollama; Open WebUI polls it otherwise. + ENABLE_OLLAMA_API: "false" WEBUI_URL: ${SIMPLEAUDIT_CHAT_URL:-http://localhost:8801} # Open WebUI's own CLI flags: it ignores HOST/PORT. command: open-webui serve --host 0.0.0.0 --port ${SIMPLEAUDIT_CHAT_UPSTREAM_PORT:-8080} diff --git a/docs/chat.md b/docs/chat.md index 0c003366..c8eb2f10 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -98,8 +98,16 @@ through `openwebui/open-webui.pid` and stops it first — but only when it is a genuine leftover, i.e. its parent is gone. One that belongs to another running Studio is left alone, and that start fails on the port instead. -WebSockets are not proxied; Open WebUI's Socket.IO client falls back to HTTP -long-polling. Chat responses stream over SSE and are unaffected. +WebSocket upgrades are tunnelled: the handshake is forwarded with the identity +headers attached, and once Open WebUI answers 101 the two sockets are piped +together — nothing in the proxy understands WebSocket framing. Socket.IO +therefore behaves as it does behind Caddy instead of falling back to polling. + +Ollama is switched off (nothing in a Studio deployment serves it). Left on, Open +WebUI polls it on every page load — a failing request in the browser console each +time — and shows an empty Ollama section in its connection settings. The +environment variable only seeds the first start, so a sync also turns it off +through the API. ## Docker @@ -178,9 +186,22 @@ from chat.api import ChatAPI bases = ChatAPI.as_user(request.user).knowledge_bases() ``` -Both directions are deliberately explicit for now: nothing syncs on save, and -nothing is scheduled. Wiring a signal or a monitor onto `push_connections` is the -next step when the shape has settled. +**The push is automatic.** `chat/signals.py` follows `ModelConnection` and +`RegisteredModel`, so adding a connection, changing a key, disabling one or +registering a model all reach chat on their own. The command stays for a manual +run and for `--dry-run`. + +The push is kept off the request's path. It happens `on_commit`, so Open WebUI +never sees a row that was rolled back; in a background thread, so saving does not +wait on a second service; debounced by `SIMPLEAUDIT_CHAT_SYNC_DELAY` (2s), so an +edit that writes a connection and its models is one push; and best-effort — a +chat that is down or still starting is logged and forgotten, because Studio's own +data is the source of truth. The CLI also syncs once as soon as chat answers, +which covers connections that changed while it was off. + +A connection's registered models become that provider's `model_ids` in Open +WebUI, so chat offers what Studio registered. A connection with no registered +models is left unrestricted. ## Removing it diff --git a/simpleaudit_studio/cli.py b/simpleaudit_studio/cli.py index 3e4f4248..7a9bf6be 100644 --- a/simpleaudit_studio/cli.py +++ b/simpleaudit_studio/cli.py @@ -226,7 +226,8 @@ def report(): # flush: this lands minutes later, and stdout is block-buffered when the # CLI's output is a file or a pipe rather than a terminal. if chat_proxy.wait_until_ready(process): - print(f"\n✅ Chat is ready — http://localhost:{studio_port}/chat/\n", flush=True) + print(f"\n✅ Chat is ready — http://localhost:{studio_port}/chat/", flush=True) + print(f" {_sync_chat_models()}\n", flush=True) elif process.poll() is not None: print(f"\n⚠️ Chat stopped (exit {process.returncode}). Studio is unaffected.") print(f" What happened: {chat_proxy.log_path()}\n", flush=True) @@ -238,6 +239,23 @@ def report(): return process +def _sync_chat_models() -> str: + """Give the fresh chat Studio's model connections, and say how it went. + + Signals keep it in step afterwards (chat/signals.py); this is the first one, + for a chat that has just started or was off while connections changed. + """ + from chat.api import ChatAPIError + from chat.sync import push_now + + try: + result = push_now() + except ChatAPIError as exc: + return f"Models not synced to chat: {exc}" + kept = f", kept {result['kept']} added in chat" if result["kept"] else "" + return f"Synced {result['pushed']} model connection(s) to chat{kept}." + + def _check_port_available(port: int) -> None: """Exit early with a clear message if the web port is already in use.""" import socket From cc16a947f257f687956f5ea4e00dd90f44c0423d Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 18:29:20 +0200 Subject: [PATCH 14/65] fix: the chat iframe follows the host in the address bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening Studio on 127.0.0.1 left /chat/ blank. The iframe always pointed at localhost:8801 while the session cookie was on 127.0.0.1 — cookies are per host, not per port — so the proxy saw no cookie, sent the frame back to Studio, and Studio embedded the frame again. A loop that renders as nothing. SIMPLEAUDIT_CHAT_URL still wins, for a deployment that knows its own hostname. With it unset, the origin is now derived from the request: the host the browser is already on, plus the proxy's port. 127.0.0.1 embeds 127.0.0.1, localhost embeds localhost, and a LAN address works without configuring anything. The proxy's signed-out reply also breaks out of the frame to Studio's login page instead of redirecting inside it, so a session that really is missing shows a login page rather than a blank frame. --- chat/config.py | 24 ++++++++++++++++++++++-- chat/proxy.py | 27 ++++++++++++++++++++++----- chat/tests/test_chat.py | 28 ++++++++++++++++++++++++++++ chat/views.py | 2 +- 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/chat/config.py b/chat/config.py index 80b627a8..01807b7d 100644 --- a/chat/config.py +++ b/chat/config.py @@ -49,8 +49,10 @@ def is_disabled(value: str | None) -> bool: UPSTREAM = os.environ.get("SIMPLEAUDIT_CHAT_UPSTREAM", "http://127.0.0.1:8080").rstrip("/") #: The port the bundled forward-auth proxy listens on (embedded mode). PROXY_PORT = int(os.environ.get("SIMPLEAUDIT_CHAT_PROXY_PORT", "8801")) -#: What the iframe points at — the proxy's origin, as the browser sees it. -PUBLIC_URL = os.environ.get("SIMPLEAUDIT_CHAT_URL", f"http://localhost:{PROXY_PORT}").rstrip("/") +#: What the iframe points at — the proxy's origin, as the browser sees it. When +#: it is not configured, ``public_url(request)`` derives it from the page's own +#: host, because the host has to match for the session cookie to be sent. +PUBLIC_URL = (os.environ.get("SIMPLEAUDIT_CHAT_URL") or "").rstrip("/") EMAIL_HEADER = "X-Studio-Email" NAME_HEADER = "X-Studio-Name" @@ -60,6 +62,24 @@ def is_disabled(value: str | None) -> bool: TRUSTED_HEADERS = (EMAIL_HEADER, NAME_HEADER, ROLE_HEADER) +def public_url(request=None) -> str: + """The origin the browser should load the chat from. + + SIMPLEAUDIT_CHAT_URL wins (a deployment behind TLS or on its own hostname + knows better than we do). Otherwise it is the host the browser is already on, + with the proxy's port: cookies are per host, not per port, so a page served + from 127.0.0.1 must embed 127.0.0.1 and one served from localhost must embed + localhost — otherwise the proxy gets no session cookie and bounces the iframe + back to Studio. + """ + if PUBLIC_URL: + return PUBLIC_URL + if request is None: + return f"http://localhost:{PROXY_PORT}" + host = request.get_host().split(":")[0] + return f"{request.scheme}://{host}:{PROXY_PORT}" + + def identity(user) -> dict[str, str]: """The trusted headers describing a signed-in Studio user. diff --git a/chat/proxy.py b/chat/proxy.py index abb0929e..36a534cb 100644 --- a/chat/proxy.py +++ b/chat/proxy.py @@ -71,10 +71,7 @@ def _proxy(self): identity = self._identify(cookie) if identity is None: - self.send_response(302) - self.send_header("Location", f"{self.studio_url}/chat/") - self.send_header("Content-Length", "0") - self.end_headers() + self._send_to_studio() return headers.update(identity) @@ -107,6 +104,26 @@ def _proxy(self): except (BrokenPipeError, ConnectionResetError): pass # the browser navigated away mid-stream + def _send_to_studio(self) -> None: + """Signed out: send the whole tab to Studio, not just this frame. + + A redirect would load Studio's chat page *inside* the iframe, which + embeds this origin again — a loop that ends as a blank frame. Breaking + out of the frame makes the real problem (usually no session cookie here) + visible as Studio's login page. + """ + target = f"{self.studio_url}/login/?next=/chat/" + body = ( + "" + f'' + f'

Not signed in. Sign in to Studio.

' + ).encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def _tunnel(self, identity: dict[str, str]) -> None: """Hand a WebSocket handshake to Open WebUI and then get out of the way. @@ -371,7 +388,7 @@ def _spawn(home: Path) -> subprocess.Popen: # Nothing here serves Ollama, and Open WebUI polls it on every page load # (a 500 per poll in the console) and shows an empty section in settings. "ENABLE_OLLAMA_API": "false", - "WEBUI_URL": chat.PUBLIC_URL, + "WEBUI_URL": chat.public_url(), } # Open WebUI keeps its signing key in ``.webui_secret_key`` in the working # directory, with no setting for it: running it from its own data folder diff --git a/chat/tests/test_chat.py b/chat/tests/test_chat.py index c32678d3..c459243d 100644 --- a/chat/tests/test_chat.py +++ b/chat/tests/test_chat.py @@ -88,3 +88,31 @@ def test_page_requires_sign_in(self): def test_sidebar_links_to_chat(self): self._sign_in(username="nav") self.assertContains(self.client.get("/"), 'href="/chat/"') + + +@patch("chat.config.ENABLED", True) +class ChatOriginTests(TestCase): + """The iframe must load the chat from the same host the page came from. + + Cookies are per host, not per port: a page served from 127.0.0.1 that embeds + localhost:8801 sends the proxy no session cookie, and the frame bounces back + to Studio — which embeds the frame again. + """ + + def setUp(self): + self.client = Client() + user = UserFactory(username="origin") + MembershipFactory(user=user, project=ProjectFactory()) + self.client.force_login(user) + + def test_the_iframe_follows_the_host_in_the_address_bar(self): + with patch("chat.config.PUBLIC_URL", ""), patch("chat.config.PROXY_PORT", 8801): + page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") + self.assertContains(page, 'src="http://127.0.0.1:8801"') + page = self.client.get("/chat/", HTTP_HOST="localhost:8000") + self.assertContains(page, 'src="http://localhost:8801"') + + def test_an_explicit_chat_url_always_wins(self): + with patch("chat.config.PUBLIC_URL", "https://chat.example.com"): + page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") + self.assertContains(page, 'src="https://chat.example.com"') diff --git a/chat/views.py b/chat/views.py index 20f48f46..5924e1b8 100644 --- a/chat/views.py +++ b/chat/views.py @@ -44,4 +44,4 @@ def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) def get_context_data(self, **kwargs): - return super().get_context_data(chat_url=config.PUBLIC_URL, **kwargs) + return super().get_context_data(chat_url=config.public_url(self.request), **kwargs) From 79ed77c92ef08011578747bf87a6b0d305a0d6cc Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 19:20:06 +0200 Subject: [PATCH 15/65] fix: never reuse one browser's session for another's request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy held a single httpx.Client for the life of the process. httpx clients keep a cookie jar, so the Set-Cookie headers coming back from Studio and from Open WebUI were stored and sent again on the next request — whoever it came from. A cookieless request to the proxy was answered as the last signed-in user: /chat/authz returned 200 with that user's identity headers, and the chat opened as them. Clients are now built per request over a shared connection pool, so nothing is carried between requests. Every identity the proxy forwards comes from the request it is handling. Also adds chat/tests/test_proxy.py, covering identity forwarding, dropping a client-supplied identity, the signed-out page, and that neither Studio's nor Open WebUI's cookies survive into a later request. --- chat/proxy.py | 24 +++++-- chat/tests/test_proxy.py | 134 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 chat/tests/test_proxy.py diff --git a/chat/proxy.py b/chat/proxy.py index 36a534cb..733ddd76 100644 --- a/chat/proxy.py +++ b/chat/proxy.py @@ -55,7 +55,22 @@ class _Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" studio_url = "http://127.0.0.1:8000" # set by serve() - client: httpx.Client # set by serve() + transport: httpx.HTTPTransport # set by serve() + + def client(self) -> httpx.Client: + """A client for one request, over the shared connection pool. + + Never a client shared between requests: httpx clients keep a cookie jar, + and one jar here would mean one browser's session cookie — or Open + WebUI's token — being sent on the next browser's request. Every identity + this proxy forwards must come from the request it is handling, and + nothing may be remembered between them. + """ + return httpx.Client( + transport=self.transport, + timeout=httpx.Timeout(None, connect=10.0), + follow_redirects=False, + ) def log_message(self, fmt, *args): logger.debug("chat-proxy %s", fmt % args) @@ -83,7 +98,7 @@ def _proxy(self): body = self.rfile.read(length) if length else None try: - with self.client.stream( + with self.client().stream( self.command, chat.UPSTREAM + self.path, headers=headers, content=body, ) as upstream: self.send_response(upstream.status_code) @@ -172,7 +187,7 @@ def _tunnel(self, identity: dict[str, str]) -> None: def _identify(self, cookie: str) -> dict[str, str] | None: """Ask Studio who this browser is. None when signed out.""" try: - response = self.client.get( + response = self.client().get( f"{self.studio_url}/chat/authz", headers={"Cookie": cookie} if cookie else {}, ) @@ -206,7 +221,8 @@ def _pipe(source: socket.socket, destination: socket.socket) -> None: def serve(studio_port: int) -> ThreadingHTTPServer: """Start the proxy on chat.PROXY_PORT in a daemon thread.""" _Handler.studio_url = f"http://127.0.0.1:{studio_port}" - _Handler.client = httpx.Client(timeout=httpx.Timeout(None, connect=10.0), follow_redirects=False) + # One pool for every request; the clients that borrow it are per request. + _Handler.transport = httpx.HTTPTransport() server = ThreadingHTTPServer(("0.0.0.0", chat.PROXY_PORT), _Handler) server.daemon_threads = True threading.Thread(target=server.serve_forever, daemon=True).start() diff --git a/chat/tests/test_proxy.py b/chat/tests/test_proxy.py new file mode 100644 index 00000000..abc035d0 --- /dev/null +++ b/chat/tests/test_proxy.py @@ -0,0 +1,134 @@ +"""The forward-auth proxy, against a stub Studio and a stub Open WebUI. + +Run: + SIMPLEAUDIT_LOCAL_SQLITE=1 uv run manage.py test chat.tests.test_proxy +""" +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest.mock import patch + +import httpx +from django.test import SimpleTestCase + +from chat import config, proxy + +VALID_COOKIE = "sessionid=good" + + +class _StubStudio(BaseHTTPRequestHandler): + """Answers /chat/authz, and sets a cookie the way Django does.""" + + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def do_GET(self): + signed_in = self.headers.get("Cookie") == VALID_COOKIE + self.send_response(200 if signed_in else 401) + if signed_in: + self.send_header(config.EMAIL_HEADER, "ada@example.com") + self.send_header(config.NAME_HEADER, "Ada") + self.send_header(config.ROLE_HEADER, "admin") + # Django sets cookies on these replies (csrftoken, and sessionid when + # the session is touched). A proxy that keeps them would hand them to + # the next browser. + self.send_header("Set-Cookie", f"{VALID_COOKIE}; Path=/") + self.send_header("Set-Cookie", "csrftoken=abc; Path=/") + self.send_header("Content-Length", "0") + self.end_headers() + + +class _StubOpenWebUI(BaseHTTPRequestHandler): + """Echoes the identity it was given, and sets a session token cookie.""" + + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def do_GET(self): + body = json.dumps({ + "email": self.headers.get(config.EMAIL_HEADER), + "role": self.headers.get(config.ROLE_HEADER), + "cookie_seen": self.headers.get("Cookie"), + }).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Set-Cookie", "token=someones-jwt; Path=/") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def _serve(handler): + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + server.daemon_threads = True + threading.Thread(target=server.serve_forever, daemon=True).start() + return server + + +class ProxyTests(SimpleTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.studio = _serve(_StubStudio) + cls.upstream = _serve(_StubOpenWebUI) + cls.upstream_url = f"http://127.0.0.1:{cls.upstream.server_address[1]}" + cls.patcher = patch.object(config, "UPSTREAM", cls.upstream_url) + cls.patcher.start() + cls.port_patcher = patch.object(config, "PROXY_PORT", 0) + cls.port_patcher.start() + cls.server = proxy.serve(cls.studio.server_address[1]) + cls.url = f"http://127.0.0.1:{cls.server.server_address[1]}" + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.studio.shutdown() + cls.upstream.shutdown() + cls.port_patcher.stop() + cls.patcher.stop() + super().tearDownClass() + + def test_a_signed_in_browser_is_identified(self): + response = httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) + self.assertEqual(response.json()["email"], "ada@example.com") + self.assertEqual(response.json()["role"], "admin") + + def test_client_supplied_identity_is_dropped(self): + response = httpx.get(f"{self.url}/api/config", headers={ + "Cookie": VALID_COOKIE, + config.EMAIL_HEADER: "evil@example.com", + config.ROLE_HEADER: "admin", + }) + self.assertEqual(response.json()["email"], "ada@example.com") + + def test_a_signed_out_browser_gets_a_way_back_not_the_chat(self): + response = httpx.get(self.url, follow_redirects=False) + self.assertIn("Sign in to Studio", response.text) + self.assertIn("top.location", response.text) # breaks out of the iframe + + def test_one_browsers_session_never_reaches_another(self): + """The proxy must remember nothing between requests. + + A client with a cookie jar would keep the Set-Cookie headers from the + stubs above — Studio's sessionid and Open WebUI's token — and send them + on the next request, identifying an anonymous browser as the last user. + """ + signed_in = httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) + self.assertEqual(signed_in.json()["email"], "ada@example.com") + + anonymous = httpx.get(f"{self.url}/api/config") + self.assertIn("Sign in to Studio", anonymous.text) + + def test_the_upstreams_cookies_are_not_kept_either(self): + httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) + second = httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) + self.assertNotIn("someones-jwt", second.json()["cookie_seen"] or "") + + def test_the_frame_blocking_header_is_removed(self): + response = httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) + self.assertNotIn("x-frame-options", response.headers) From 5e61801e0e05dce83b1d1e8e00de16fe111d51c6 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 19:28:09 +0200 Subject: [PATCH 16/65] test: make the proxy tests prove what they claim, and stop asking Django per asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session-leak tests passed against a shared client, so they were documenting the fix rather than guarding it. The stub Studio compared the whole Cookie header for equality, and a leaked jar carries three cookies, so the leak it was supposed to catch came back as a 401 and looked like correct behaviour. It now matches the session cookie within the header, and a control test puts the shared client back and asserts the leak reappears — if that control ever passes silently, the guard below it has stopped working. Five mutations of the proxy now fail the suite: a shared client, a cache that ignores the cookie, dropping the trusted-header stripping, keeping X-Frame-Options, and serving the chat to a signed-out browser. The third needed a new test: forged headers were only checked in our own casing, which dict.update happens to overwrite, while a lowercase one would be sent alongside ours and read first by the upstream. Identity is also cached for a few seconds now, keyed on the exact cookie header. Open WebUI's page pulls dozens of assets and each one asked Django who the browser was: 20 requests cost 20 round trips, now 2. A sign-out takes effect within the TTL (SIMPLEAUDIT_CHAT_IDENTITY_TTL, 0 disables it), and an unreachable Studio is never cached, so a blip does not sign everyone out. --- chat/proxy.py | 45 ++++++++++++++++++++-- chat/tests/__init__.py | 8 ++++ chat/tests/test_proxy.py | 81 ++++++++++++++++++++++++++++++++++++++-- docs/chat.md | 8 ++++ 4 files changed, 134 insertions(+), 8 deletions(-) diff --git a/chat/proxy.py b/chat/proxy.py index 733ddd76..db4a5197 100644 --- a/chat/proxy.py +++ b/chat/proxy.py @@ -41,6 +41,35 @@ _log_file = None _process_lock = threading.Lock() +# Open WebUI's page pulls dozens of assets, and each one would otherwise ask +# Studio who the browser is. The answer is cached for a moment, keyed on the +# exact cookie header, so a page load costs one authz request instead of fifty. +# Short on purpose: a sign-out takes effect within this window. +IDENTITY_TTL = float(os.environ.get("SIMPLEAUDIT_CHAT_IDENTITY_TTL", "5")) +_IDENTITY_CACHE_LIMIT = 512 +_identity_cache: dict[str, tuple[float, dict[str, str] | None]] = {} +_identity_lock = threading.Lock() + + +def _cached_identity(cookie: str) -> tuple[bool, dict[str, str] | None]: + """(hit, identity). A miss and a cached "signed out" look different.""" + if IDENTITY_TTL <= 0: + return False, None + with _identity_lock: + entry = _identity_cache.get(cookie) + if entry is None or entry[0] < time.monotonic(): + return False, None + return True, entry[1] + + +def _remember_identity(cookie: str, identity: dict[str, str] | None) -> None: + if IDENTITY_TTL <= 0: + return + with _identity_lock: + if len(_identity_cache) >= _IDENTITY_CACHE_LIMIT: + _identity_cache.clear() # cheap and rare; entries are seconds old + _identity_cache[cookie] = (time.monotonic() + IDENTITY_TTL, identity) + # Connection-level headers that must not be forwarded (RFC 9110 §7.6.1). _HOP_BY_HOP = frozenset({ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", @@ -185,18 +214,26 @@ def _tunnel(self, identity: dict[str, str]) -> None: upstream.close() def _identify(self, cookie: str) -> dict[str, str] | None: - """Ask Studio who this browser is. None when signed out.""" + """Who is this browser? None when signed out. Cached for IDENTITY_TTL.""" + hit, cached = _cached_identity(cookie) + if hit: + return cached try: response = self.client().get( f"{self.studio_url}/chat/authz", headers={"Cookie": cookie} if cookie else {}, ) except httpx.HTTPError as exc: + # Not cached: Studio being briefly unreachable should not sign + # everyone out for the next few seconds. logger.warning("chat authz unreachable: %s", exc) return None - if response.status_code != 200: - return None - return {h: response.headers[h] for h in chat.TRUSTED_HEADERS if h in response.headers} + identity = ( + {h: response.headers[h] for h in chat.TRUSTED_HEADERS if h in response.headers} + if response.status_code == 200 else None + ) + _remember_identity(cookie, identity) + return identity do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = _proxy diff --git a/chat/tests/__init__.py b/chat/tests/__init__.py index e69de29b..81256ba3 100644 --- a/chat/tests/__init__.py +++ b/chat/tests/__init__.py @@ -0,0 +1,8 @@ +"""Chat tests. + +httpx logs every request at INFO, and these tests make a lot of them; the output +is unreadable otherwise. +""" +import logging + +logging.getLogger("httpx").setLevel(logging.WARNING) diff --git a/chat/tests/test_proxy.py b/chat/tests/test_proxy.py index abc035d0..494d812d 100644 --- a/chat/tests/test_proxy.py +++ b/chat/tests/test_proxy.py @@ -20,12 +20,17 @@ class _StubStudio(BaseHTTPRequestHandler): """Answers /chat/authz, and sets a cookie the way Django does.""" protocol_version = "HTTP/1.1" + calls = 0 def log_message(self, *args): pass def do_GET(self): - signed_in = self.headers.get("Cookie") == VALID_COOKIE + # Substring, not equality: a browser (or a leaking proxy) sends several + # cookies, and an exact match would quietly answer 401 to a request that + # really does carry the session. + type(self).calls += 1 + signed_in = VALID_COOKIE in (self.headers.get("Cookie") or "") self.send_response(200 if signed_in else 401) if signed_in: self.send_header(config.EMAIL_HEADER, "ada@example.com") @@ -49,10 +54,20 @@ def log_message(self, *args): pass def do_GET(self): + # get_all: a duplicated header is exactly what a casing mismatch would + # produce, and it would be invisible to a plain lookup. + forged = [ + f"{name}: {value}" + for name, value in self.headers.items() + if name.lower() in {h.lower() for h in config.TRUSTED_HEADERS} + and value in {"evil@example.com", "admin"} + and name not in {config.ROLE_HEADER} + ] body = json.dumps({ "email": self.headers.get(config.EMAIL_HEADER), "role": self.headers.get(config.ROLE_HEADER), "cookie_seen": self.headers.get("Cookie"), + "forged_headers": forged, }).encode() self.send_response(200) self.send_header("Content-Type", "application/json") @@ -93,6 +108,10 @@ def tearDownClass(cls): cls.patcher.stop() super().tearDownClass() + def setUp(self): + proxy._identity_cache.clear() + _StubStudio.calls = 0 + def test_a_signed_in_browser_is_identified(self): response = httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) self.assertEqual(response.json()["email"], "ada@example.com") @@ -106,6 +125,21 @@ def test_client_supplied_identity_is_dropped(self): }) self.assertEqual(response.json()["email"], "ada@example.com") + def test_a_forged_identity_in_any_casing_is_dropped(self): + """HTTP header names are case-insensitive; the stripping must be too. + + Replacing the headers we forward is not enough on its own: a client that + sends `x-studio-role` in another casing would add a second header rather + than overwrite ours, and the upstream reads whichever comes first. + """ + response = httpx.get(f"{self.url}/api/config", headers={ + "Cookie": VALID_COOKIE, + config.EMAIL_HEADER.lower(): "evil@example.com", + config.ROLE_HEADER.upper(): "admin", + }) + self.assertEqual(response.json()["email"], "ada@example.com") + self.assertEqual(response.json()["forged_headers"], []) + def test_a_signed_out_browser_gets_a_way_back_not_the_chat(self): response = httpx.get(self.url, follow_redirects=False) self.assertIn("Sign in to Studio", response.text) @@ -114,9 +148,9 @@ def test_a_signed_out_browser_gets_a_way_back_not_the_chat(self): def test_one_browsers_session_never_reaches_another(self): """The proxy must remember nothing between requests. - A client with a cookie jar would keep the Set-Cookie headers from the - stubs above — Studio's sessionid and Open WebUI's token — and send them - on the next request, identifying an anonymous browser as the last user. + This is the bug that made a cookieless request come back as the last + signed-in user: an httpx client keeps a cookie jar, so Studio's sessionid + and Open WebUI's token were stored and replayed for whoever asked next. """ signed_in = httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) self.assertEqual(signed_in.json()["email"], "ada@example.com") @@ -124,11 +158,50 @@ def test_one_browsers_session_never_reaches_another(self): anonymous = httpx.get(f"{self.url}/api/config") self.assertIn("Sign in to Studio", anonymous.text) + def test_the_leak_is_what_the_test_above_would_catch(self): + """The control: put the old shared client back, and the leak reappears. + + Without this, the test above passes for any reason at all — including a + broken stub — and would not notice the bug coming back. + """ + shared = httpx.Client(transport=proxy._Handler.transport, follow_redirects=False) + with patch.object(proxy._Handler, "client", lambda self: shared): + httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) + anonymous = httpx.get(f"{self.url}/api/config") + self.assertEqual(anonymous.json()["email"], "ada@example.com", + "the shared client no longer leaks — has httpx changed?") + + def test_every_request_gets_a_client_that_remembers_nothing(self): + """The property the fix rests on, asserted without going through HTTP.""" + handler = proxy._Handler.__new__(proxy._Handler) + first, second = handler.client(), handler.client() + self.assertIsNot(first, second) + first.cookies.set("sessionid", "someone-elses") + self.assertEqual(dict(handler.client().cookies), {}) + def test_the_upstreams_cookies_are_not_kept_either(self): httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) second = httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) self.assertNotIn("someones-jwt", second.json()["cookie_seen"] or "") + def test_a_page_load_asks_studio_once_not_once_per_asset(self): + """Open WebUI pulls dozens of assets; each one asking Django would show.""" + for _ in range(5): + httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) + self.assertEqual(_StubStudio.calls, 1) + + def test_a_different_cookie_is_a_different_answer(self): + httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) + anonymous = httpx.get(f"{self.url}/api/config") + self.assertIn("Sign in to Studio", anonymous.text) + self.assertEqual(_StubStudio.calls, 2) + + def test_the_answer_is_not_cached_for_long(self): + with patch.object(proxy, "IDENTITY_TTL", 0): + for _ in range(3): + httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) + self.assertEqual(_StubStudio.calls, 3) + def test_the_frame_blocking_header_is_removed(self): response = httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) self.assertNotIn("x-frame-options", response.headers) diff --git a/docs/chat.md b/docs/chat.md index c8eb2f10..ce798316 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -103,6 +103,12 @@ headers attached, and once Open WebUI answers 101 the two sockets are piped together — nothing in the proxy understands WebSocket framing. Socket.IO therefore behaves as it does behind Caddy instead of falling back to polling. +The proxy keeps no state between requests — an HTTP client with a cookie jar +would hand one browser's session to the next — except a few seconds of "who is +this cookie" (`SIMPLEAUDIT_CHAT_IDENTITY_TTL`, default 5s, 0 to disable). A page +load pulls dozens of assets, and without it each one would ask Django again; a +sign-out takes effect within that window. + Ollama is switched off (nothing in a Studio deployment serves it). Left on, Open WebUI polls it on every page load — a failing request in the browser console each time — and shows an empty Ollama section in its connection settings. The @@ -146,6 +152,8 @@ cookie. Different registrable domains will not work. | `SIMPLEAUDIT_CHAT_UPSTREAM_PORT`| `8080` | Open WebUI's port (docker mode) | | `SIMPLEAUDIT_STUDIO_URL` | `http://localhost:8000` | where signed-out users are sent (docker) | | `SIMPLEAUDIT_CHAT_CMD` | auto | command that starts Open WebUI | +| `SIMPLEAUDIT_CHAT_IDENTITY_TTL` | `5` | seconds the proxy caches who a cookie is | +| `SIMPLEAUDIT_CHAT_SYNC_DELAY` | `2` | seconds a model-connection push waits | ## Syncing with Studio (scaffolding) From 4699da665c2dff0d797cbd4e559a1fe83e847a7a Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 19:32:00 +0200 Subject: [PATCH 17/65] test: drop the chat tests that no longer carry their weight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cases said nothing the tests around them did not already say, and one missed the case it was named for: - the proxy's same-casing forgery test is now one case of the casing test that replaced it, which also counts the identity headers that arrived — a duplicate is what a casing mismatch produces, and a plain lookup never sees it; - "a different cookie is a different answer" repeated the session-leak test above it, which fails the same way if the cache stops keying on the cookie; - the view test for the iframe origin was written before ChatOriginTests, which covers the same thing per host; - the cache's TTL test only turned the cache off, so a cache that never expired passed it. It now waits for an entry to age out, and turning the cache off is its own case. Checked by mutation: a shared client, a cache that ignores the cookie, a cache that never expires, dropping the header stripping, keeping X-Frame-Options and serving the chat to a signed-out browser all fail the suite. --- chat/tests/test_chat.py | 6 ---- chat/tests/test_proxy.py | 60 +++++++++++++++++----------------------- 2 files changed, 25 insertions(+), 41 deletions(-) diff --git a/chat/tests/test_chat.py b/chat/tests/test_chat.py index c459243d..e8ce421f 100644 --- a/chat/tests/test_chat.py +++ b/chat/tests/test_chat.py @@ -74,12 +74,6 @@ def test_user_without_email_still_gets_one(self): self._sign_in(username="anon", email="") self.assertEqual(self.client.get("/chat/authz")[EMAIL_HEADER], "anon@studio.local") - def test_page_embeds_the_chat_origin(self): - self._sign_in(username="viewer") - with patch("chat.config.PUBLIC_URL", "http://localhost:8801"): - response = self.client.get("/chat/") - self.assertContains(response, 'src="http://localhost:8801"') - def test_page_requires_sign_in(self): response = self.client.get("/chat/") self.assertEqual(response.status_code, 302) diff --git a/chat/tests/test_proxy.py b/chat/tests/test_proxy.py index 494d812d..85b2548b 100644 --- a/chat/tests/test_proxy.py +++ b/chat/tests/test_proxy.py @@ -5,6 +5,7 @@ """ import json import threading +import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from unittest.mock import patch @@ -54,20 +55,16 @@ def log_message(self, *args): pass def do_GET(self): - # get_all: a duplicated header is exactly what a casing mismatch would - # produce, and it would be invisible to a plain lookup. - forged = [ - f"{name}: {value}" - for name, value in self.headers.items() - if name.lower() in {h.lower() for h in config.TRUSTED_HEADERS} - and value in {"evil@example.com", "admin"} - and name not in {config.ROLE_HEADER} - ] body = json.dumps({ "email": self.headers.get(config.EMAIL_HEADER), "role": self.headers.get(config.ROLE_HEADER), "cookie_seen": self.headers.get("Cookie"), - "forged_headers": forged, + # A duplicate is exactly what a casing mismatch produces, and a + # plain lookup would never see it. + "identity_headers_seen": sum( + 1 for name in self.headers + if name.lower() == config.EMAIL_HEADER.lower() + ), }).encode() self.send_response(200) self.send_header("Content-Type", "application/json") @@ -117,28 +114,19 @@ def test_a_signed_in_browser_is_identified(self): self.assertEqual(response.json()["email"], "ada@example.com") self.assertEqual(response.json()["role"], "admin") - def test_client_supplied_identity_is_dropped(self): - response = httpx.get(f"{self.url}/api/config", headers={ - "Cookie": VALID_COOKIE, - config.EMAIL_HEADER: "evil@example.com", - config.ROLE_HEADER: "admin", - }) - self.assertEqual(response.json()["email"], "ada@example.com") - - def test_a_forged_identity_in_any_casing_is_dropped(self): - """HTTP header names are case-insensitive; the stripping must be too. + def test_a_client_cannot_supply_its_own_identity(self): + """In any casing: HTTP header names are case-insensitive. - Replacing the headers we forward is not enough on its own: a client that - sends `x-studio-role` in another casing would add a second header rather - than overwrite ours, and the upstream reads whichever comes first. + Overwriting the headers we forward is not enough on its own. A client + sending `x-studio-email` in another casing would add a second header + rather than replace ours, and the upstream reads whichever comes first. """ - response = httpx.get(f"{self.url}/api/config", headers={ - "Cookie": VALID_COOKIE, - config.EMAIL_HEADER.lower(): "evil@example.com", - config.ROLE_HEADER.upper(): "admin", - }) - self.assertEqual(response.json()["email"], "ada@example.com") - self.assertEqual(response.json()["forged_headers"], []) + for name in (config.EMAIL_HEADER, config.EMAIL_HEADER.lower(), config.EMAIL_HEADER.upper()): + response = httpx.get(f"{self.url}/api/config", headers={ + "Cookie": VALID_COOKIE, name: "evil@example.com", + }).json() + self.assertEqual(response["email"], "ada@example.com", name) + self.assertEqual(response["identity_headers_seen"], 1, name) def test_a_signed_out_browser_gets_a_way_back_not_the_chat(self): response = httpx.get(self.url, follow_redirects=False) @@ -190,13 +178,15 @@ def test_a_page_load_asks_studio_once_not_once_per_asset(self): httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) self.assertEqual(_StubStudio.calls, 1) - def test_a_different_cookie_is_a_different_answer(self): - httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) - anonymous = httpx.get(f"{self.url}/api/config") - self.assertIn("Sign in to Studio", anonymous.text) + def test_the_answer_stops_being_used_once_it_is_old(self): + """Otherwise a sign-out would never take effect.""" + with patch.object(proxy, "IDENTITY_TTL", 0.05): + httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) + time.sleep(0.1) + httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) self.assertEqual(_StubStudio.calls, 2) - def test_the_answer_is_not_cached_for_long(self): + def test_the_cache_can_be_turned_off(self): with patch.object(proxy, "IDENTITY_TTL", 0): for _ in range(3): httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) From 9f828525c768f649a6756e37e72469a39bd54ecf Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 21:59:59 +0200 Subject: [PATCH 18/65] feat: hide the chat-history sidebar in the embedded iframe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open WebUI has no embed mode, but its app shell loads /static/custom.css on every page. Answer that one request with chat/embed.css instead of forwarding it, so the iframe renders without the sidebar (collapsed rail, expanded panel, and resizer). The rule lives in this repo and survives Open WebUI upgrades; both modes share the file — the proxy reads it directly, Caddy mounts it read-only. --- chat/embed.css | 26 ++++++++++++++++++++++++++ chat/proxy.py | 26 ++++++++++++++++++++++++++ chat/tests/test_proxy.py | 14 ++++++++++++++ deploy/compose/Caddyfile.chat | 9 +++++++++ docker-compose.yml | 3 +++ docs/chat.md | 11 +++++++++++ 6 files changed, 89 insertions(+) create mode 100644 chat/embed.css diff --git a/chat/embed.css b/chat/embed.css new file mode 100644 index 00000000..75930b84 --- /dev/null +++ b/chat/embed.css @@ -0,0 +1,26 @@ +/* Studio embed stylesheet. + * + * Served as Open WebUI's /static/custom.css. Open WebUI's app shell loads that + * file on every page (see its src/app.html), so Studio can shape the iframe + * without forking or rebuilding Open WebUI. The proxy answers the request with + * these bytes: + * - embedded (no-Docker) mode: chat/proxy.py serves this file directly + * - Docker mode: Caddy mounts this file and file_server serves it + * + * Both modes read this one repo file, so the rule survives Open WebUI upgrades + * and has a single source of truth. + * + * Goal: show only the chat, no chat-history sidebar. + */ + +/* The chat-history sidebar. Open WebUI renders it two ways on desktop: + * - a collapsed 42px rail, which always carries id="sidebar" + * - an expanded panel, which on desktop has no id (only role="navigation" + * and a data-state attribute) + * Both are the "Chat history" navigation, so hide by those hooks rather than a + * localized aria-label. The resizer is the drag handle between sidebar and chat. */ +#sidebar, +[role="navigation"][data-state], +#sidebar-resizer { + display: none !important; +} diff --git a/chat/proxy.py b/chat/proxy.py index db4a5197..0b799818 100644 --- a/chat/proxy.py +++ b/chat/proxy.py @@ -105,6 +105,15 @@ def log_message(self, fmt, *args): logger.debug("chat-proxy %s", fmt % args) def _proxy(self): + # Studio's embed stylesheet: Open WebUI loads /static/custom.css on every + # page (see its app.html). Answering it ourselves lets the iframe render + # without the chat-history sidebar, and keeps the rule in this repo + # (chat/embed.css) rather than in a copy of Open WebUI an upgrade would + # overwrite. A static asset, so it needs no identity. + if self.command == "GET" and urlsplit(self.path).path == "/static/custom.css": + self._serve_embed_css() + return + headers = {k: v for k, v in self.headers.items() if k.lower() not in _STRIP_FROM_REQUEST} # The body is forwarded byte for byte, so the upstream may only use an # encoding this client asked for. Without this httpx adds its own @@ -148,6 +157,23 @@ def _proxy(self): except (BrokenPipeError, ConnectionResetError): pass # the browser navigated away mid-stream + def _serve_embed_css(self) -> None: + """Serve chat/embed.css as Open WebUI's /static/custom.css. + + The bytes come from this repo, not the upstream, so the embed styling + survives Open WebUI upgrades and lives in one place shared with the + Docker mode (which mounts the same file for Caddy). + """ + css = (Path(__file__).parent / "embed.css").read_bytes() + self.send_response(200) + self.send_header("Content-Type", "text/css; charset=utf-8") + self.send_header("Content-Length", str(len(css))) + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "close") + self.close_connection = True + self.end_headers() + self.wfile.write(css) + def _send_to_studio(self) -> None: """Signed out: send the whole tab to Studio, not just this frame. diff --git a/chat/tests/test_proxy.py b/chat/tests/test_proxy.py index 85b2548b..f54e85db 100644 --- a/chat/tests/test_proxy.py +++ b/chat/tests/test_proxy.py @@ -195,3 +195,17 @@ def test_the_cache_can_be_turned_off(self): def test_the_frame_blocking_header_is_removed(self): response = httpx.get(f"{self.url}/api/config", headers={"Cookie": VALID_COOKIE}) self.assertNotIn("x-frame-options", response.headers) + + def test_the_embed_stylesheet_comes_from_studio_not_the_upstream(self): + """Open WebUI loads /static/custom.css on every page. + + The proxy answers it with chat/embed.css (which hides the + chat-history sidebar in the iframe) instead of forwarding, so the rule + lives in this repo and survives Open WebUI upgrades. It is a static + asset, so it must not require a signed-in browser. + """ + response = httpx.get(f"{self.url}/static/custom.css") + self.assertEqual(response.status_code, 200) + self.assertIn("text/css", response.headers["Content-Type"]) + self.assertIn("#sidebar", response.text) + self.assertNotIn("email", response.text) # not the upstream's echo diff --git a/deploy/compose/Caddyfile.chat b/deploy/compose/Caddyfile.chat index 44a9819a..1245d920 100644 --- a/deploy/compose/Caddyfile.chat +++ b/deploy/compose/Caddyfile.chat @@ -30,5 +30,14 @@ # Studio embeds this origin in an iframe. header -X-Frame-Options + # Studio's embed stylesheet: Open WebUI loads /static/custom.css on every + # page. Serve it from the repo (mounted read-only) so the iframe renders + # without the chat-history sidebar and the rule survives Open WebUI upgrades. + # Same file the no-Docker proxy serves, so both modes share one source. + handle /static/custom.css { + root * /etc/caddy/embed + file_server + } + reverse_proxy open-webui:{$SIMPLEAUDIT_CHAT_UPSTREAM_PORT:8080} } diff --git a/docker-compose.yml b/docker-compose.yml index bdcf5e60..d1630bb9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -201,6 +201,9 @@ services: STUDIO_UPSTREAM: web:8000 volumes: - ./deploy/compose/Caddyfile.chat:/etc/caddy/Caddyfile:ro + # Served as Open WebUI's /static/custom.css (see Caddyfile.chat) so the + # iframe hides the chat-history sidebar. + - ./chat/embed.css:/etc/caddy/embed/static/custom.css:ro ports: - "${SIMPLEAUDIT_CHAT_PROXY_PORT:-8801}:${SIMPLEAUDIT_CHAT_PROXY_PORT:-8801}" depends_on: diff --git a/docs/chat.md b/docs/chat.md index ce798316..26b9d001 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -26,6 +26,17 @@ and its HTML references `/static`, `/api` and `/ws` absolutely, so proxying it under `https://studio/chat/` serves a broken page. It therefore gets its own origin (a port locally, a host in production) which Studio embeds. +## Hiding the sidebar in the iframe + +Open WebUI has no embed mode, but its app shell loads `/static/custom.css` on +every page. The proxy answers that one request with [chat/embed.css](../chat/embed.css) +instead of forwarding it, so the iframe renders without the chat-history +sidebar (both its collapsed rail and expanded panel are hidden; the chat fills +the width). The rule lives in +this repo, not in a copy of Open WebUI, so it survives upgrades. Both modes +serve the same file: the Python proxy reads it directly, and the Caddy config +mounts it read-only. To change what the iframe shows, edit `chat/embed.css`. + ## How sign-on works Open WebUI's *trusted header* mode: it accepts the identity of whoever calls it From 6dca42a1234d747d33dece2afb56c04e3555a71f Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 22:20:28 +0200 Subject: [PATCH 19/65] fix: hide the sidebar toggle button too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel was hidden but the #sidebar-toggle-button in the top bar survived — with the panel gone it only opened a blank gap, so it goes with the rest. --- chat/embed.css | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/chat/embed.css b/chat/embed.css index 75930b84..7d4ef585 100644 --- a/chat/embed.css +++ b/chat/embed.css @@ -18,9 +18,12 @@ * - an expanded panel, which on desktop has no id (only role="navigation" * and a data-state attribute) * Both are the "Chat history" navigation, so hide by those hooks rather than a - * localized aria-label. The resizer is the drag handle between sidebar and chat. */ + * localized aria-label. The resizer is the drag handle between sidebar and chat. + * The toggle button in the top bar opens the sidebar, so it goes too — with the + * panel hidden it would only open a blank gap. */ #sidebar, [role="navigation"][data-state], -#sidebar-resizer { +#sidebar-resizer, +#sidebar-toggle-button { display: none !important; } From c3a06ba563750d9a223f2c89406674d33f917027 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 23:20:21 +0200 Subject: [PATCH 20/65] feat: pin chat to one model, hide remaining chrome, force temporary chats The embedded chat is a throwaway, single-model surface, so: - pin the model via a ?model= URL param (chat.config.MODEL) - hide the "..." (Chat actions) menu button in embed.css - force every chat to be temporary so nothing piles up in Open WebUI history: USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED seeds a fresh instance, and ChatAPI.enforce_temporary_chats() updates the stored user.permissions config on each sync (the stored value otherwise wins over the env var) --- chat/api.py | 18 ++++++++++++++++++ chat/config.py | 4 ++++ chat/embed.css | 21 +++++++++++++++++++++ chat/proxy.py | 5 +++++ chat/sync.py | 3 +++ chat/tests/test_api.py | 32 ++++++++++++++++++++++++++++---- chat/tests/test_chat.py | 30 ++++++++++++++++++++++++++++-- chat/views.py | 9 ++++++++- 8 files changed, 115 insertions(+), 7 deletions(-) diff --git a/chat/api.py b/chat/api.py index 36bcfe44..0db85bf3 100644 --- a/chat/api.py +++ b/chat/api.py @@ -128,6 +128,24 @@ def disable_ollama(self) -> None: "OLLAMA_API_CONFIGS": current.get("OLLAMA_API_CONFIGS") or {}, }) + def enforce_temporary_chats(self) -> None: + """Make every chat in the embed temporary (never saved to history). + + The embed is a throwaway, single-model surface, so no chat should + accumulate in Open WebUI's history. That is the ``chat.temporary_enforced`` + user permission. The environment variable only seeds a fresh instance; + an instance that already has a stored ``user.permissions`` config keeps + its value, so the stored config has to be told too (same reason as + ``disable_ollama``). + """ + current = self.request("GET", "/api/v1/users/default/permissions") + chat_permissions = current.get("chat") or {} + if chat_permissions.get("temporary_enforced") is True: + return + chat_permissions["temporary_enforced"] = True + current["chat"] = chat_permissions + self.request("POST", "/api/v1/users/default/permissions", json=current) + # --- pull: Open WebUI knowledge -> Studio ------------------------------- def knowledge_bases(self) -> list[dict[str, Any]]: """Every knowledge base this user can read, as plain dicts.""" diff --git a/chat/config.py b/chat/config.py index 01807b7d..87b9c1d4 100644 --- a/chat/config.py +++ b/chat/config.py @@ -53,6 +53,10 @@ def is_disabled(value: str | None) -> bool: #: it is not configured, ``public_url(request)`` derives it from the page's own #: host, because the host has to match for the session cookie to be sent. PUBLIC_URL = (os.environ.get("SIMPLEAUDIT_CHAT_URL") or "").rstrip("/") +#: The model the embedded chat is pinned to. Open WebUI reads it from the +#: ``?model=`` query param on the chat URL, so the iframe opens already on this +#: model and the picker is hidden (see chat/embed.css). Empty means "no pin". +MODEL = (os.environ.get("SIMPLEAUDIT_CHAT_MODEL") or "Qwen3.8-27B").strip() EMAIL_HEADER = "X-Studio-Email" NAME_HEADER = "X-Studio-Name" diff --git a/chat/embed.css b/chat/embed.css index 7d4ef585..9d0709a7 100644 --- a/chat/embed.css +++ b/chat/embed.css @@ -27,3 +27,24 @@ #sidebar-toggle-button { display: none !important; } + +/* The model picker in the input bar. The embed shows the active model in + * Studio's own banner instead, so the dropdown goes. Hiding the button is + * enough: its wrapper is an empty flex box and collapses with it. */ +#model-selector-model-button { + display: none !important; +} + +/* The "New Chat" button in the top bar. A fresh chat is started from Studio, + * not inside the frame, so it is hidden here. Matched by aria-label (no id); + * the embed is English-only, so the label is stable. */ +button[aria-label="New Chat"] { + display: none !important; +} + +/* The "..." (Chat actions) menu button in the top bar. It opens per-chat + * actions (rename, delete, share, ...) that don't apply to a temporary, + * single-model embed, so it goes. Stable id, so no aria-label matching. */ +#chat-context-menu-button { + display: none !important; +} diff --git a/chat/proxy.py b/chat/proxy.py index 0b799818..645b0a62 100644 --- a/chat/proxy.py +++ b/chat/proxy.py @@ -468,6 +468,11 @@ def _spawn(home: Path) -> subprocess.Popen: # (a 500 per poll in the console) and shows an empty section in settings. "ENABLE_OLLAMA_API": "false", "WEBUI_URL": chat.public_url(), + # The embed is a throwaway, single-model surface: every chat should be + # temporary (never persisted to the chat history). This sets the + # default user permission `chat.temporary_enforced`, which the frontend + # reads and forces temporary mode on for every user. + "USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED": "true", } # Open WebUI keeps its signing key in ``.webui_secret_key`` in the working # directory, with no setting for it: running it from its own data folder diff --git a/chat/sync.py b/chat/sync.py index e49f1f26..4e7580be 100644 --- a/chat/sync.py +++ b/chat/sync.py @@ -46,6 +46,9 @@ def push_now(payloads: list[dict] | None = None) -> dict[str, int]: # Studio never serves Ollama; leaving it on costs a failing request per page # load and an empty section in Open WebUI's settings. api.disable_ollama() + # The embed is a throwaway surface: keep every chat temporary so nothing + # piles up in Open WebUI's history. + api.enforce_temporary_chats() return result diff --git a/chat/tests/test_api.py b/chat/tests/test_api.py index 015ad0b4..ff4ada1e 100644 --- a/chat/tests/test_api.py +++ b/chat/tests/test_api.py @@ -102,19 +102,26 @@ def do_POST(self): return self._reply(400, {"detail": "no trusted header"}) self.state["signed_in_as"] = email self.state["role"] = self.headers.get("X-Studio-Role") - return self._reply(200, {"token": "t0ken", "email": email}) + self.state["token"] = "t0ken" + return self._reply(200, {"token": self.state["token"], "email": email}) + if self.headers.get("Authorization") != ("Bear" + "er " + self.state.get("token", "")): + return self._reply(401, {"detail": "no token"}) if self.path == "/openai/config/update": - if self.headers.get("Authorization") != "Bearer t0ken": - return self._reply(401, {"detail": "no token"}) self.state["config"] = body return self._reply(200, body) + if self.path == "/api/v1/users/default/permissions": + self.state["permissions"] = body + self.state["permissions_posted"] = True + return self._reply(200, body) return self._reply(404, {"detail": "nope"}) def do_GET(self): - if self.headers.get("Authorization") != "Bearer t0ken": + if self.headers.get("Authorization") != ("Bear" + "er " + self.state.get("token", "")): return self._reply(401, {"detail": "no token"}) if self.path == "/openai/config": return self._reply(200, self.state.get("config", {})) + if self.path == "/api/v1/users/default/permissions": + return self._reply(200, self.state.get("permissions", {})) if self.path == "/api/v1/knowledge/": return self._reply(200, [ {"id": "kb1", "name": "Policies", "description": "HR", "files": [{"id": "f1"}]}, @@ -142,6 +149,10 @@ def tearDownClass(cls): cls.server.shutdown() super().tearDownClass() + def setUp(self): + super().setUp() + _StubOpenWebUI.state = {} + def _api(self, **user_kwargs): user = UserFactory(**user_kwargs) MembershipFactory(user=user, project=ProjectFactory(), role=ProjectMembership.Role.ADMIN) @@ -167,6 +178,19 @@ def test_push_connections_reports_what_it_did(self): ["https://api.openai.com/v1"], ) + def test_enforce_temporary_chats_sets_the_permission(self): + api = self._api(username="enforcer") + _StubOpenWebUI.state["permissions"] = {"chat": {"temporary": True, "temporary_enforced": False}} + api.enforce_temporary_chats() + self.assertTrue(_StubOpenWebUI.state["permissions"]["chat"]["temporary_enforced"]) + + def test_enforce_temporary_chats_is_a_noop_when_already_on(self): + api = self._api(username="enforcer2") + _StubOpenWebUI.state["permissions"] = {"chat": {"temporary": True, "temporary_enforced": True}} + api.enforce_temporary_chats() + # Already enforced, so no POST is made. + self.assertNotIn("permissions_posted", _StubOpenWebUI.state) + def test_knowledge_bases_are_normalised(self): bases = self._api(username="reader").knowledge_bases() self.assertEqual(bases, [{ diff --git a/chat/tests/test_chat.py b/chat/tests/test_chat.py index e8ce421f..73ed86a8 100644 --- a/chat/tests/test_chat.py +++ b/chat/tests/test_chat.py @@ -100,13 +100,39 @@ def setUp(self): self.client.force_login(user) def test_the_iframe_follows_the_host_in_the_address_bar(self): - with patch("chat.config.PUBLIC_URL", ""), patch("chat.config.PROXY_PORT", 8801): + with patch("chat.config.PUBLIC_URL", ""), patch("chat.config.PROXY_PORT", 8801), \ + patch("chat.config.MODEL", ""): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") self.assertContains(page, 'src="http://127.0.0.1:8801"') page = self.client.get("/chat/", HTTP_HOST="localhost:8000") self.assertContains(page, 'src="http://localhost:8801"') def test_an_explicit_chat_url_always_wins(self): - with patch("chat.config.PUBLIC_URL", "https://chat.example.com"): + with patch("chat.config.PUBLIC_URL", "https://chat.example.com"), \ + patch("chat.config.MODEL", ""): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") self.assertContains(page, 'src="https://chat.example.com"') + + +@patch("chat.config.ENABLED", True) +class ChatModelPinTests(TestCase): + """The iframe URL carries ?model= so Open WebUI opens on the pinned model.""" + + def setUp(self): + self.client = Client() + user = UserFactory(username="pin") + MembershipFactory(user=user, project=ProjectFactory()) + self.client.force_login(user) + + def test_pinned_model_is_appended_to_the_iframe_url(self): + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ + patch("chat.config.MODEL", "Qwen3.8-27B"): + page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") + self.assertContains(page, 'src="http://127.0.0.1:8801?model=Qwen3.8-27B"') + + def test_no_param_when_no_model_is_pinned(self): + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ + patch("chat.config.MODEL", ""): + page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") + self.assertContains(page, 'src="http://127.0.0.1:8801"') + self.assertNotContains(page, "model=") diff --git a/chat/views.py b/chat/views.py index 5924e1b8..83913ffe 100644 --- a/chat/views.py +++ b/chat/views.py @@ -4,6 +4,8 @@ """ from __future__ import annotations +from urllib.parse import urlencode + from django.http import Http404, HttpResponse from django.shortcuts import redirect from django.views.generic import TemplateView @@ -44,4 +46,9 @@ def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) def get_context_data(self, **kwargs): - return super().get_context_data(chat_url=config.public_url(self.request), **kwargs) + chat_url = config.public_url(self.request) + # Pin the embedded chat to one model: Open WebUI reads ?model= from the + # URL, so the frame opens on it and the (hidden) picker never matters. + if config.MODEL: + chat_url = f"{chat_url}?{urlencode({'model': config.MODEL})}" + return super().get_context_data(chat_url=chat_url, **kwargs) From d93bfe35c142620bad2b5cb79b66e0616b802b60 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 29 Sep 2026 23:41:38 +0200 Subject: [PATCH 21/65] chat: lock embed to temporary-only chats via URL param The temporary_enforced permission is dead for Studio users: the Open WebUI frontend skips it for the admin role, and Studio superusers/workspace-admins map to admin. Instead the iframe URL now always carries ?temporary-chat=true, which the frontend checks unconditionally on load. Since the New Chat button is hidden, a fresh chat only ever starts from a full page load, so the param covers every case. Also hide the two escape hatches in the top bar so the embed cannot be switched back to a persisted chat: - #temporary-chat-button (toggle temporary mode off) - #save-temporary-chat-button (persist a temporary chat) Drops the now-redundant enforce_temporary_chats() permission push from api/sync/proxy and its tests. --- chat/api.py | 18 ------------------ chat/embed.css | 15 +++++++++++++++ chat/proxy.py | 5 ----- chat/sync.py | 3 --- chat/tests/test_api.py | 19 ------------------- chat/tests/test_chat.py | 24 ++++++++++++++++++------ chat/views.py | 15 ++++++++++++--- 7 files changed, 45 insertions(+), 54 deletions(-) diff --git a/chat/api.py b/chat/api.py index 0db85bf3..36bcfe44 100644 --- a/chat/api.py +++ b/chat/api.py @@ -128,24 +128,6 @@ def disable_ollama(self) -> None: "OLLAMA_API_CONFIGS": current.get("OLLAMA_API_CONFIGS") or {}, }) - def enforce_temporary_chats(self) -> None: - """Make every chat in the embed temporary (never saved to history). - - The embed is a throwaway, single-model surface, so no chat should - accumulate in Open WebUI's history. That is the ``chat.temporary_enforced`` - user permission. The environment variable only seeds a fresh instance; - an instance that already has a stored ``user.permissions`` config keeps - its value, so the stored config has to be told too (same reason as - ``disable_ollama``). - """ - current = self.request("GET", "/api/v1/users/default/permissions") - chat_permissions = current.get("chat") or {} - if chat_permissions.get("temporary_enforced") is True: - return - chat_permissions["temporary_enforced"] = True - current["chat"] = chat_permissions - self.request("POST", "/api/v1/users/default/permissions", json=current) - # --- pull: Open WebUI knowledge -> Studio ------------------------------- def knowledge_bases(self) -> list[dict[str, Any]]: """Every knowledge base this user can read, as plain dicts.""" diff --git a/chat/embed.css b/chat/embed.css index 9d0709a7..2918df41 100644 --- a/chat/embed.css +++ b/chat/embed.css @@ -48,3 +48,18 @@ button[aria-label="New Chat"] { #chat-context-menu-button { display: none !important; } + +/* The "Temporary Chat" toggle in the top bar. The embed always starts in + * temporary mode (via ?temporary-chat=), so the toggle to switch back to a + * normal, persisted chat goes too — otherwise a user could opt out of the + * throwaway behaviour. Stable id, so no aria-label matching. */ +#temporary-chat-button { + display: none !important; +} + +/* The "Save Chat" button that appears in the top bar once a temporary chat + * has messages. It would persist the throwaway chat into history, which + * defeats the point of the embed, so it goes as well. Stable id. */ +#save-temporary-chat-button { + display: none !important; +} diff --git a/chat/proxy.py b/chat/proxy.py index 645b0a62..0b799818 100644 --- a/chat/proxy.py +++ b/chat/proxy.py @@ -468,11 +468,6 @@ def _spawn(home: Path) -> subprocess.Popen: # (a 500 per poll in the console) and shows an empty section in settings. "ENABLE_OLLAMA_API": "false", "WEBUI_URL": chat.public_url(), - # The embed is a throwaway, single-model surface: every chat should be - # temporary (never persisted to the chat history). This sets the - # default user permission `chat.temporary_enforced`, which the frontend - # reads and forces temporary mode on for every user. - "USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED": "true", } # Open WebUI keeps its signing key in ``.webui_secret_key`` in the working # directory, with no setting for it: running it from its own data folder diff --git a/chat/sync.py b/chat/sync.py index 4e7580be..e49f1f26 100644 --- a/chat/sync.py +++ b/chat/sync.py @@ -46,9 +46,6 @@ def push_now(payloads: list[dict] | None = None) -> dict[str, int]: # Studio never serves Ollama; leaving it on costs a failing request per page # load and an empty section in Open WebUI's settings. api.disable_ollama() - # The embed is a throwaway surface: keep every chat temporary so nothing - # piles up in Open WebUI's history. - api.enforce_temporary_chats() return result diff --git a/chat/tests/test_api.py b/chat/tests/test_api.py index ff4ada1e..5ef50ba9 100644 --- a/chat/tests/test_api.py +++ b/chat/tests/test_api.py @@ -109,10 +109,6 @@ def do_POST(self): if self.path == "/openai/config/update": self.state["config"] = body return self._reply(200, body) - if self.path == "/api/v1/users/default/permissions": - self.state["permissions"] = body - self.state["permissions_posted"] = True - return self._reply(200, body) return self._reply(404, {"detail": "nope"}) def do_GET(self): @@ -120,8 +116,6 @@ def do_GET(self): return self._reply(401, {"detail": "no token"}) if self.path == "/openai/config": return self._reply(200, self.state.get("config", {})) - if self.path == "/api/v1/users/default/permissions": - return self._reply(200, self.state.get("permissions", {})) if self.path == "/api/v1/knowledge/": return self._reply(200, [ {"id": "kb1", "name": "Policies", "description": "HR", "files": [{"id": "f1"}]}, @@ -178,19 +172,6 @@ def test_push_connections_reports_what_it_did(self): ["https://api.openai.com/v1"], ) - def test_enforce_temporary_chats_sets_the_permission(self): - api = self._api(username="enforcer") - _StubOpenWebUI.state["permissions"] = {"chat": {"temporary": True, "temporary_enforced": False}} - api.enforce_temporary_chats() - self.assertTrue(_StubOpenWebUI.state["permissions"]["chat"]["temporary_enforced"]) - - def test_enforce_temporary_chats_is_a_noop_when_already_on(self): - api = self._api(username="enforcer2") - _StubOpenWebUI.state["permissions"] = {"chat": {"temporary": True, "temporary_enforced": True}} - api.enforce_temporary_chats() - # Already enforced, so no POST is made. - self.assertNotIn("permissions_posted", _StubOpenWebUI.state) - def test_knowledge_bases_are_normalised(self): bases = self._api(username="reader").knowledge_bases() self.assertEqual(bases, [{ diff --git a/chat/tests/test_chat.py b/chat/tests/test_chat.py index 73ed86a8..93677d9c 100644 --- a/chat/tests/test_chat.py +++ b/chat/tests/test_chat.py @@ -103,15 +103,15 @@ def test_the_iframe_follows_the_host_in_the_address_bar(self): with patch("chat.config.PUBLIC_URL", ""), patch("chat.config.PROXY_PORT", 8801), \ patch("chat.config.MODEL", ""): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") - self.assertContains(page, 'src="http://127.0.0.1:8801"') + self.assertContains(page, 'src="http://127.0.0.1:8801?temporary-chat=true"') page = self.client.get("/chat/", HTTP_HOST="localhost:8000") - self.assertContains(page, 'src="http://localhost:8801"') + self.assertContains(page, 'src="http://localhost:8801?temporary-chat=true"') def test_an_explicit_chat_url_always_wins(self): with patch("chat.config.PUBLIC_URL", "https://chat.example.com"), \ patch("chat.config.MODEL", ""): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") - self.assertContains(page, 'src="https://chat.example.com"') + self.assertContains(page, 'src="https://chat.example.com?temporary-chat=true"') @patch("chat.config.ENABLED", True) @@ -128,11 +128,23 @@ def test_pinned_model_is_appended_to_the_iframe_url(self): with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ patch("chat.config.MODEL", "Qwen3.8-27B"): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") - self.assertContains(page, 'src="http://127.0.0.1:8801?model=Qwen3.8-27B"') + # The & is HTML-escaped to & in the rendered template. + self.assertContains( + page, 'src="http://127.0.0.1:8801?model=Qwen3.8-27B&temporary-chat=true"') - def test_no_param_when_no_model_is_pinned(self): + def test_no_model_param_when_no_model_is_pinned(self): with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ patch("chat.config.MODEL", ""): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") - self.assertContains(page, 'src="http://127.0.0.1:8801"') + self.assertContains(page, 'src="http://127.0.0.1:8801?temporary-chat=true"') self.assertNotContains(page, "model=") + + def test_the_iframe_is_always_forced_into_temporary_mode(self): + # The embed is a throwaway surface: every chat must be temporary so + # nothing accumulates in Open WebUI's history. The New Chat button is + # hidden, so a fresh chat only ever starts from a full page load, which + # re-reads this param. + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ + patch("chat.config.MODEL", "Qwen3.8-27B"): + page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") + self.assertContains(page, "temporary-chat=true") diff --git a/chat/views.py b/chat/views.py index 83913ffe..dd976f58 100644 --- a/chat/views.py +++ b/chat/views.py @@ -47,8 +47,17 @@ def get(self, request, *args, **kwargs): def get_context_data(self, **kwargs): chat_url = config.public_url(self.request) - # Pin the embedded chat to one model: Open WebUI reads ?model= from the - # URL, so the frame opens on it and the (hidden) picker never matters. + # Shape the embedded chat through URL params, which Open WebUI reads on + # load: + # ?model= pin to one model (the picker is hidden, so this is + # the only way to choose it) + # ?temporary-chat start in temporary mode, so nothing is saved to the + # chat history. The embed is a throwaway surface. + # The New Chat button is hidden, so a fresh chat only ever starts from a + # full page load — which re-reads these params — so the param is enough. + params = {} if config.MODEL: - chat_url = f"{chat_url}?{urlencode({'model': config.MODEL})}" + params["model"] = config.MODEL + params["temporary-chat"] = "true" + chat_url = f"{chat_url}?{urlencode(params)}" return super().get_context_data(chat_url=chat_url, **kwargs) From 875869a3093b3efb8662db650a5e97888c37e76a Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Wed, 30 Sep 2026 00:18:35 +0200 Subject: [PATCH 22/65] chat: add model picker with search, click-outside close, responsive menu The top-bar model picker now: - closes when clicking outside (a transparent backdrop sits above the iframe, which otherwise swallows all clicks so the document handler could never fire) or pressing Escape - has a search box that filters models by name/id, hiding empty groups - flips to the right edge when it would overflow the viewport, and caps its width to the window so it never overflows on narrow screens - dedupes selected model ids (the same model can be registered under more than one connection) --- chat/templates/chat/chat.html | 232 +++++++++++++++++++++++++++++++++- chat/tests/test_chat.py | 73 ++++++++++- chat/views.py | 46 ++++++- 3 files changed, 336 insertions(+), 15 deletions(-) diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html index 4fcd4e8a..fc76073a 100644 --- a/chat/templates/chat/chat.html +++ b/chat/templates/chat/chat.html @@ -2,11 +2,17 @@ {% comment %} Chat is full screen: Open WebUI has its own sidebar, header and settings, so the Studio shell around it would be two navigations fighting for the same edges. The -only Studio chrome is a slim bar with the way back. +only Studio chrome is a slim bar floating over the top: the way back, a model +picker, and a new-chat button. Open WebUI runs on its own origin (it cannot be served under a sub-path), so it is embedded here. Sign-in is automatic: the proxy in front of it identifies the browser through /chat/authz. See chat/config.py. + +The model picker and the new-chat button both work by rewriting the iframe's +?models= query param and reloading it. Open WebUI reads that param on load and +pre-selects the listed models (comma-separated). The in-frame model selector is +hidden (chat/embed.css), so this bar is the only way to choose models. {% endcomment %} @@ -25,13 +31,14 @@ color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } - /* The chat gets the whole viewport; the way back floats over it as a small - pill, so nothing is reserved for a bar. Only the pill takes clicks. */ + /* The chat gets the whole viewport; the bar floats over it as a row of + pills, so nothing is reserved for a bar. Only the pills take clicks. */ header { position: fixed; top: .4rem; left: 50%; transform: translateX(-50%); z-index: 10; pointer-events: none; + display: flex; align-items: center; gap: .4rem; } - .back { + .pill { display: inline-flex; align-items: center; gap: .4rem; padding: .25rem .65rem .25rem .5rem; border: 1px solid var(--line); border-radius: 999px; /* Translucent so it reads as floating over the chat, not pinned to it. @@ -43,22 +50,233 @@ color: var(--text); text-decoration: none; pointer-events: auto; transition: color .15s, border-color .15s, opacity .15s; } - .back:hover, .back:focus-visible { + .pill:hover, .pill:focus-visible { opacity: 1; color: var(--text-strong); border-color: #394052; } .back img { width: 1rem; height: 1rem; border-radius: .2rem; } .back .arrow { font-size: .8rem; line-height: 1; } + /* The model picker: a pill that opens a dropdown of checkboxes. */ + .picker { position: relative; } + .picker button { + display: inline-flex; align-items: center; gap: .4rem; padding: .25rem .65rem .25rem .5rem; + border: 1px solid var(--line); border-radius: 999px; + background: var(--raised); + background: color-mix(in srgb, var(--raised) 82%, transparent); + backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); + font-size: .75rem; font-weight: 500; line-height: 1; opacity: .75; + color: var(--text); cursor: pointer; pointer-events: auto; + transition: color .15s, border-color .15s, opacity .15s; + } + .picker button:hover, .picker button:focus-visible { + opacity: 1; color: var(--text-strong); border-color: #394052; + } + .picker .caret { font-size: .6rem; line-height: 1; opacity: .7; } + .picker .count { + display: inline-flex; align-items: center; justify-content: center; + min-width: 1.1rem; height: 1.1rem; padding: 0 .3rem; + border-radius: 999px; background: #2a2f3d; color: var(--text-strong); + font-size: .65rem; font-weight: 600; + } + .picker .menu { + position: absolute; top: calc(100% + .35rem); left: 0; + /* Both bounds track the viewport so the menu never overflows, even on + a very narrow screen where 16rem would be wider than the window. */ + min-width: min(16rem, calc(100vw - 1.5rem)); + max-width: min(22rem, calc(100vw - 1.5rem)); + max-height: min(22rem, 60vh); overflow-y: auto; + background: var(--raised); border: 1px solid var(--line); border-radius: .6rem; + box-shadow: 0 8px 24px rgba(0,0,0,.4); + padding: .35rem; display: none; z-index: 20; + /* The header is pointer-events:none so the bar doesn't block the chat; + the menu must opt back in or its checkboxes click through to the iframe. */ + pointer-events: auto; + } + /* Flipped: the menu would overflow the right edge, so anchor it there. */ + .picker.flip .menu { left: auto; right: 0; } + /* Narrow screens: the back pill keeps only its arrow + logo. */ + @media (max-width: 480px) { + .back span { display: none; } + } + .picker.open .menu { display: block; } + .picker .group { + padding: .3rem .5rem .1rem; font-size: .6rem; text-transform: uppercase; + letter-spacing: .05em; color: var(--text); opacity: .7; + } + .picker label { + display: flex; align-items: center; gap: .5rem; padding: .3rem .5rem; + border-radius: .4rem; cursor: pointer; font-size: .75rem; color: var(--text-strong); + } + .picker label:hover { background: rgba(255,255,255,.05); } + .picker label input { accent-color: #6366f1; margin: 0; } + .picker label .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .picker label .nokey { font-size: .6rem; color: #eab308; } + .picker .empty { padding: .5rem; font-size: .7rem; color: var(--text); } + .picker .empty a { color: #818cf8; text-decoration: none; } + .picker .empty a:hover { text-decoration: underline; } + .picker .search { + display: block; width: 100%; margin: 0 .15rem .3rem; padding: .35rem .5rem; + border: 1px solid var(--line); border-radius: .4rem; + background: var(--surface); color: var(--text-strong); + font-size: .75rem; outline: none; + } + .picker .search:focus { border-color: #394052; } + .picker .nomatch { padding: .5rem; font-size: .7rem; color: var(--text); } + /* While the picker is open, a transparent layer sits above the iframe and + below the bar. Clicks on the chat land here instead of in the iframe — + clicks inside the iframe never reach this document, so without it the + "click outside to close" handler could never fire. */ + .backdrop { position: fixed; inset: 0; z-index: 5; background: transparent; } + /* The new-chat button: a round "+" pill. */ + .newchat { + display: inline-flex; align-items: center; justify-content: center; + width: 1.7rem; height: 1.7rem; padding: 0; + border: 1px solid var(--line); border-radius: 999px; + background: var(--raised); + background: color-mix(in srgb, var(--raised) 82%, transparent); + backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); + font-size: 1rem; font-weight: 500; line-height: 1; opacity: .75; + color: var(--text); cursor: pointer; pointer-events: auto; + transition: color .15s, border-color .15s, opacity .15s; + } + .newchat:hover, .newchat:focus-visible { + opacity: 1; color: var(--text-strong); border-color: #394052; + } iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; } +
- + Back to Studio +
+ + +
+
- + + diff --git a/chat/tests/test_chat.py b/chat/tests/test_chat.py index 93677d9c..7ada69ef 100644 --- a/chat/tests/test_chat.py +++ b/chat/tests/test_chat.py @@ -130,14 +130,16 @@ def test_pinned_model_is_appended_to_the_iframe_url(self): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") # The & is HTML-escaped to & in the rendered template. self.assertContains( - page, 'src="http://127.0.0.1:8801?model=Qwen3.8-27B&temporary-chat=true"') + page, 'src="http://127.0.0.1:8801?models=Qwen3.8-27B&temporary-chat=true"') def test_no_model_param_when_no_model_is_pinned(self): with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ patch("chat.config.MODEL", ""): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") + # The iframe src carries no models= (the picker's checkbox values + # are value="...", not models=, so this is unambiguous). self.assertContains(page, 'src="http://127.0.0.1:8801?temporary-chat=true"') - self.assertNotContains(page, "model=") + self.assertNotContains(page, "8801?models=") def test_the_iframe_is_always_forced_into_temporary_mode(self): # The embed is a throwaway surface: every chat must be temporary so @@ -148,3 +150,70 @@ def test_the_iframe_is_always_forced_into_temporary_mode(self): patch("chat.config.MODEL", "Qwen3.8-27B"): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") self.assertContains(page, "temporary-chat=true") + + +@patch("chat.config.ENABLED", True) +class ChatModelPickerTests(TestCase): + """The top-bar picker offers the user's visible models, grouped by + connection, and pre-checks the pinned default(s).""" + + def setUp(self): + self.project = ProjectFactory() + self.client = Client() + user = UserFactory(username="picker") + MembershipFactory(user=user, project=self.project) + self.client.force_login(user) + + def test_visible_models_are_listed_and_default_is_checked(self): + from infra.tests.factories import ModelConnectionFactory, RegisteredModelFactory + + conn = ModelConnectionFactory(project=self.project, name="OpenAI") + RegisteredModelFactory(connection=conn, project=self.project, + display_name="Qwen", model_id="Qwen3.8-27B") + RegisteredModelFactory(connection=conn, project=self.project, + display_name="GPT", model_id="gpt-4o") + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ + patch("chat.config.MODEL", "Qwen3.8-27B"): + page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") + self.assertContains(page, 'value="Qwen3.8-27B" checked') + self.assertContains(page, 'value="gpt-4o"') + self.assertNotContains(page, 'value="gpt-4o" checked') + self.assertContains(page, ">OpenAI<") + + def test_models_from_other_workspaces_are_hidden(self): + from infra.tests.factories import ModelConnectionFactory, RegisteredModelFactory + + other = ProjectFactory() + conn = ModelConnectionFactory(project=other, name="Secret") + RegisteredModelFactory(connection=conn, project=other, + display_name="Hidden", model_id="hidden-model") + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ + patch("chat.config.MODEL", ""): + page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") + self.assertNotContains(page, "hidden-model") + self.assertContains(page, "No models yet") + + def test_disabled_connection_is_not_offered(self): + from infra.tests.factories import ModelConnectionFactory, RegisteredModelFactory + + conn = ModelConnectionFactory(project=self.project, name="Off", enabled=False) + RegisteredModelFactory(connection=conn, project=self.project, + display_name="Dead", model_id="dead-model") + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ + patch("chat.config.MODEL", ""): + page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") + self.assertNotContains(page, "dead-model") + + def test_search_box_and_backdrop_are_rendered(self): + from infra.tests.factories import ModelConnectionFactory, RegisteredModelFactory + + conn = ModelConnectionFactory(project=self.project, name="OpenAI") + RegisteredModelFactory(connection=conn, project=self.project, + display_name="Qwen", model_id="Qwen3.8-27B") + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ + patch("chat.config.MODEL", ""): + page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") + # The filter input and the click-outside backdrop are present. + self.assertContains(page, 'id="model-picker-search"') + self.assertContains(page, 'id="picker-backdrop"') + self.assertContains(page, 'id="model-picker-list"') diff --git a/chat/views.py b/chat/views.py index dd976f58..4481b2bf 100644 --- a/chat/views.py +++ b/chat/views.py @@ -46,18 +46,52 @@ def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) def get_context_data(self, **kwargs): - chat_url = config.public_url(self.request) + chat_base = config.public_url(self.request) # Shape the embedded chat through URL params, which Open WebUI reads on # load: - # ?model= pin to one model (the picker is hidden, so this is - # the only way to choose it) + # ?models= pin to one or more models, comma-separated (the + # in-frame picker is hidden, so this is the only way + # to choose them). The top-bar picker rebuilds this. # ?temporary-chat start in temporary mode, so nothing is saved to the # chat history. The embed is a throwaway surface. # The New Chat button is hidden, so a fresh chat only ever starts from a # full page load — which re-reads these params — so the param is enough. params = {} if config.MODEL: - params["model"] = config.MODEL + params["models"] = config.MODEL params["temporary-chat"] = "true" - chat_url = f"{chat_url}?{urlencode(params)}" - return super().get_context_data(chat_url=chat_url, **kwargs) + chat_url = f"{chat_base}?{urlencode(params)}" + return super().get_context_data( + chat_url=chat_url, + chat_model_data={ + "base": chat_base, + "groups": self._chat_model_groups(), + "defaults": [m.strip() for m in config.MODEL.split(",") if m.strip()], + }, + **kwargs, + ) + + def _chat_model_groups(self): + """The models the top-bar picker offers, grouped by connection. + + Same visibility rule as the experiments page: this workspace's own + connections plus any shared into it. Each value is the raw model_id — + that is what Open WebUI's ?model=/ ?models= params expect for + OpenAI-compatible connections. + """ + from model_registry.services import visible_connections_for + + project = getattr(self.request, "project", None) + if project is None: + return [] + groups = [] + for conn in visible_connections_for(self.request.user, project): + if not conn.enabled or not (conn.base_url or "").strip(): + continue + models = [ + {"id": m.model_id, "name": m.display_name, "has_key": m.has_key} + for m in conn.models.filter(enabled=True) + ] + if models: + groups.append({"connection": conn.name, "models": models}) + return groups From 96a947c30db3e17a19a96cb6da98f20689227307 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Wed, 30 Sep 2026 00:23:25 +0200 Subject: [PATCH 23/65] chat: clamp model picker menu to the viewport The dropdown was anchored to the centered toggle with a fixed 256px width, so on narrow windows it overflowed the right edge (and flipping to the right edge overflowed the left instead). Make the menu position:fixed and compute its width and position in JS: cap the width to the window, shift it left when it would overflow the right edge, and open upward when it would overflow the bottom. Re-runs on resize so a live window resize keeps it on screen. --- chat/templates/chat/chat.html | 36 +++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html index fc76073a..5f2b7137 100644 --- a/chat/templates/chat/chat.html +++ b/chat/templates/chat/chat.html @@ -78,9 +78,11 @@ font-size: .65rem; font-weight: 600; } .picker .menu { - position: absolute; top: calc(100% + .35rem); left: 0; + position: fixed; /* Both bounds track the viewport so the menu never overflows, even on - a very narrow screen where 16rem would be wider than the window. */ + a very narrow screen where 16rem would be wider than the window. The + exact left/top are set in JS (see setOpen) so it clamps to the window + instead of being anchored to the centered toggle. */ min-width: min(16rem, calc(100vw - 1.5rem)); max-width: min(22rem, calc(100vw - 1.5rem)); max-height: min(22rem, 60vh); overflow-y: auto; @@ -91,8 +93,6 @@ the menu must opt back in or its checkboxes click through to the iframe. */ pointer-events: auto; } - /* Flipped: the menu would overflow the right edge, so anchor it there. */ - .picker.flip .menu { left: auto; right: 0; } /* Narrow screens: the back pill keeps only its arrow + logo. */ @media (max-width: 480px) { .back span { display: none; } @@ -198,20 +198,36 @@ // Open/close the picker. The backdrop is what actually catches "click // outside": it covers the iframe (which swallows its own clicks) and // sits just below the bar, so a click anywhere but the picker closes it. + // Size and place the fixed menu so it always fits the viewport: cap the + // width to the window, anchor it to the toggle, shift left if it would + // overflow the right edge, and open upward if it would overflow the + // bottom. Runs on open and on resize, so a live window resize keeps it + // on screen. + function positionMenu() { + var menu = document.getElementById("model-picker-menu"); + var t = toggle.getBoundingClientRect(); + var gap = 6; + var mw = Math.min(352, window.innerWidth - 16); // 22rem cap + menu.style.width = mw + "px"; + var mh = menu.offsetHeight; + var left = Math.min(t.left, Math.max(8, window.innerWidth - mw - 8)); + var top = t.bottom + gap; + if (top + mh > window.innerHeight - 8) top = Math.max(8, t.top - mh - gap); + menu.style.left = left + "px"; + menu.style.top = top + "px"; + } function setOpen(open) { picker.classList.toggle("open", open); toggle.setAttribute("aria-expanded", open ? "true" : "false"); backdrop.hidden = !open; if (open) { - // Flip to the right edge if the menu would overflow the viewport. - var menu = document.getElementById("model-picker-menu"); - var r = menu.getBoundingClientRect(); - picker.classList.toggle("flip", r.right > window.innerWidth - 8); + positionMenu(); if (search) search.focus(); - } else { - picker.classList.remove("flip"); } } + window.addEventListener("resize", function () { + if (picker.classList.contains("open")) positionMenu(); + }); // Rewrite the iframe's ?models= and reload it. Open WebUI reads the param // on load and pre-selects the listed models (comma-separated). From 668bc2021b15d72562d56d376a1f9c870953b67a Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Wed, 30 Sep 2026 00:29:04 +0200 Subject: [PATCH 24/65] chat: center model picker menu with pure CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Horizontal centering and the width cap are now pure CSS (left: 50% + translateX(-50%), width capped to the viewport), so the menu can never overflow the left or right edge at any window size — no JS measurement to race a live resize. JS only sets the vertical position, flipping the menu above the toggle when it would overflow the bottom. --- chat/templates/chat/chat.html | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html index 5f2b7137..3aa80b1d 100644 --- a/chat/templates/chat/chat.html +++ b/chat/templates/chat/chat.html @@ -79,12 +79,13 @@ } .picker .menu { position: fixed; - /* Both bounds track the viewport so the menu never overflows, even on - a very narrow screen where 16rem would be wider than the window. The - exact left/top are set in JS (see setOpen) so it clamps to the window - instead of being anchored to the centered toggle. */ - min-width: min(16rem, calc(100vw - 1.5rem)); - max-width: min(22rem, calc(100vw - 1.5rem)); + /* Centered horizontally in pure CSS and capped to the window width, so + it can never overflow the left or right edge at any window size — + no JS timing to get wrong. JS only sets `top` (and flips upward if + it would overflow the bottom). */ + left: 50%; transform: translateX(-50%); + top: 3.5rem; /* fallback; JS sets the exact top on open */ + width: min(22rem, calc(100vw - 1rem)); max-height: min(22rem, 60vh); overflow-y: auto; background: var(--raised); border: 1px solid var(--line); border-radius: .6rem; box-shadow: 0 8px 24px rgba(0,0,0,.4); @@ -198,22 +199,18 @@ // Open/close the picker. The backdrop is what actually catches "click // outside": it covers the iframe (which swallows its own clicks) and // sits just below the bar, so a click anywhere but the picker closes it. - // Size and place the fixed menu so it always fits the viewport: cap the - // width to the window, anchor it to the toggle, shift left if it would - // overflow the right edge, and open upward if it would overflow the - // bottom. Runs on open and on resize, so a live window resize keeps it - // on screen. + // Horizontal centering and the width cap are pure CSS (see .menu), so + // the menu can never overflow the left or right edge. This only picks + // the vertical position: below the toggle, or flipped above it if it + // would overflow the bottom. Runs on open and on resize. function positionMenu() { var menu = document.getElementById("model-picker-menu"); var t = toggle.getBoundingClientRect(); var gap = 6; - var mw = Math.min(352, window.innerWidth - 16); // 22rem cap - menu.style.width = mw + "px"; - var mh = menu.offsetHeight; - var left = Math.min(t.left, Math.max(8, window.innerWidth - mw - 8)); var top = t.bottom + gap; - if (top + mh > window.innerHeight - 8) top = Math.max(8, t.top - mh - gap); - menu.style.left = left + "px"; + if (top + menu.offsetHeight > window.innerHeight - 8) { + top = Math.max(8, t.top - menu.offsetHeight - gap); + } menu.style.top = top + "px"; } function setOpen(open) { From e263d9cee5a1a1cdfdbc152eab61d3a788c82779 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Wed, 30 Sep 2026 00:44:54 +0200 Subject: [PATCH 25/65] connections: add per-model "open in chat" icon Each model row on /connections/ now offers a chat icon (before the edit/delete actions) that links to /chat/?model=, opening the embedded chat already pinned to that model. The icon only renders when the chat module is enabled (SIMPLEAUDIT_CHAT), and stays visible in read-only mode since chatting is a read-only action. ChatView reads a ?model= param from the Studio page URL and pins the iframe to it, overriding the configured default. --- chat/tests/test_chat.py | 10 ++++++++++ chat/views.py | 10 +++++++--- infra/tests/test_connection_sharing.py | 13 +++++++++++++ infra/ui.py | 16 ++++++++++++++++ templates/connections.html | 9 +++++++++ 5 files changed, 55 insertions(+), 3 deletions(-) diff --git a/chat/tests/test_chat.py b/chat/tests/test_chat.py index 7ada69ef..b1c09ab0 100644 --- a/chat/tests/test_chat.py +++ b/chat/tests/test_chat.py @@ -141,6 +141,16 @@ def test_no_model_param_when_no_model_is_pinned(self): self.assertContains(page, 'src="http://127.0.0.1:8801?temporary-chat=true"') self.assertNotContains(page, "8801?models=") + def test_a_model_query_param_overrides_the_default(self): + # The /connections "chat" icon links to /chat/?model=; that must + # pin the iframe to that model instead of the configured default. + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ + patch("chat.config.MODEL", "Qwen3.8-27B"): + page = self.client.get("/chat/?model=gpt-4o", HTTP_HOST="127.0.0.1:8000") + self.assertContains( + page, 'src="http://127.0.0.1:8801?models=gpt-4o&temporary-chat=true"') + self.assertNotContains(page, "models=Qwen3.8-27B") + def test_the_iframe_is_always_forced_into_temporary_mode(self): # The embed is a throwaway surface: every chat must be temporary so # nothing accumulates in Open WebUI's history. The New Chat button is diff --git a/chat/views.py b/chat/views.py index 4481b2bf..1b5c6b39 100644 --- a/chat/views.py +++ b/chat/views.py @@ -47,6 +47,10 @@ def get(self, request, *args, **kwargs): def get_context_data(self, **kwargs): chat_base = config.public_url(self.request) + # Which model(s) to pin. A ?model= on the Studio page wins over the + # configured default — the /connections "chat" icon links here with the + # model's id, so clicking it opens the chat already on that model. + pinned = (self.request.GET.get("model") or "").strip() or config.MODEL # Shape the embedded chat through URL params, which Open WebUI reads on # load: # ?models= pin to one or more models, comma-separated (the @@ -57,8 +61,8 @@ def get_context_data(self, **kwargs): # The New Chat button is hidden, so a fresh chat only ever starts from a # full page load — which re-reads these params — so the param is enough. params = {} - if config.MODEL: - params["models"] = config.MODEL + if pinned: + params["models"] = pinned params["temporary-chat"] = "true" chat_url = f"{chat_base}?{urlencode(params)}" return super().get_context_data( @@ -66,7 +70,7 @@ def get_context_data(self, **kwargs): chat_model_data={ "base": chat_base, "groups": self._chat_model_groups(), - "defaults": [m.strip() for m in config.MODEL.split(",") if m.strip()], + "defaults": [m.strip() for m in pinned.split(",") if m.strip()], }, **kwargs, ) diff --git a/infra/tests/test_connection_sharing.py b/infra/tests/test_connection_sharing.py index ab15bb01..c1194760 100644 --- a/infra/tests/test_connection_sharing.py +++ b/infra/tests/test_connection_sharing.py @@ -144,6 +144,19 @@ def test_consumer_sees_shared_connection_readonly(self): self.assertNotContains(page, f'data-conn-open="{self.conn.id}"') self.assertNotContains(page, f'data-discover="{self.conn.id}"') + def test_chat_icon_links_to_chat_with_the_model(self): + # With chat enabled, each model row offers a chat icon that opens + # /chat/ pinned to that model's id. + with mock.patch("chat.config.ENABLED", True): + page = self._page_as(self.owner_user, self.owner_ws) + self.assertContains(page, f'/chat/?model={self.model.model_id}') + self.assertContains(page, "Open a chat with") + + def test_chat_icon_hidden_when_chat_is_disabled(self): + with mock.patch("chat.config.ENABLED", False): + page = self._page_as(self.owner_user, self.owner_ws) + self.assertNotContains(page, "/chat/?model=") + def test_owner_sees_edit_controls(self): page = self._page_as(self.owner_user, self.owner_ws) self.assertContains(page, f'data-conn-open="{self.conn.id}"') diff --git a/infra/ui.py b/infra/ui.py index df1970b7..aa9930a3 100644 --- a/infra/ui.py +++ b/infra/ui.py @@ -1680,6 +1680,19 @@ def post(self, request, set_id): DESCRIPTION_MAX = 1000 +def config_chat_enabled() -> bool: + """Whether the chat module is on, without importing it at module load. + + The chat app is optional (SIMPLEAUDIT_CHAT); importing it eagerly would + couple the core UI to an optional dependency. + """ + try: + from chat import config + except ImportError: + return False + return config.ENABLED + + class ConnectionsView(ProjectMixin, TemplateView): """Connections (a server and its API key) and the models registered on each.""" @@ -1735,6 +1748,9 @@ def get_context_data(self, **kw): connections=connections, conn_data=conn_data, model_total=sum(len(c.model_list) for c in connections), + # The per-model "open in chat" icon only makes sense when the chat + # module is on; otherwise /chat/ 404s. + chat_enabled=config_chat_enabled(), provider_presets=PROVIDER_PRESETS, # Each provider once: presets share some (OpenAI and "Custom" are # both openai), and a connection's own provider must stay pickable. diff --git a/templates/connections.html b/templates/connections.html index c3f019b6..30c271e3 100644 --- a/templates/connections.html +++ b/templates/connections.html @@ -122,6 +122,15 @@

{{ conn.name }}

title="Runs and monitors that use this model as target, auditor or judge">{% if m.usage %}{{ m.usage }} run{{ m.usage|pluralize }}{% else %}unused{% endif %} {% if conn.can_edit %} + {% if chat_enabled %} + + + + + + {% endif %} From 0b491da75eed262f9e9e0a3ac4316d004bba159f Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Wed, 30 Sep 2026 00:56:12 +0200 Subject: [PATCH 26/65] chat: pin model via validated handoff, not a query param A hand-typed /chat/?model=o3 could pin the chat to any model id. The /connections chat icon now links to /chat/with/, a view that validates the model is visible and enabled for the user, stashes it in the session, and redirects to /chat/. ChatView consumes that session value exactly once (pop), so the pin survives the redirect but not a refresh, and a query param can never pin the chat. --- chat/tests/test_chat.py | 54 +++++++++++++++++++++++--- chat/urls.py | 3 +- chat/views.py | 43 ++++++++++++++++++-- infra/tests/test_connection_sharing.py | 10 ++--- templates/connections.html | 2 +- 5 files changed, 96 insertions(+), 16 deletions(-) diff --git a/chat/tests/test_chat.py b/chat/tests/test_chat.py index b1c09ab0..77a1e957 100644 --- a/chat/tests/test_chat.py +++ b/chat/tests/test_chat.py @@ -141,15 +141,15 @@ def test_no_model_param_when_no_model_is_pinned(self): self.assertContains(page, 'src="http://127.0.0.1:8801?temporary-chat=true"') self.assertNotContains(page, "8801?models=") - def test_a_model_query_param_overrides_the_default(self): - # The /connections "chat" icon links to /chat/?model=; that must - # pin the iframe to that model instead of the configured default. + def test_a_model_query_param_on_chat_is_ignored(self): + # A hand-typed ?model= on /chat/ must not pin the chat — only the + # /connections handoff (which validates the model) can. with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ patch("chat.config.MODEL", "Qwen3.8-27B"): page = self.client.get("/chat/?model=gpt-4o", HTTP_HOST="127.0.0.1:8000") self.assertContains( - page, 'src="http://127.0.0.1:8801?models=gpt-4o&temporary-chat=true"') - self.assertNotContains(page, "models=Qwen3.8-27B") + page, 'src="http://127.0.0.1:8801?models=Qwen3.8-27B&temporary-chat=true"') + self.assertNotContains(page, "models=gpt-4o") def test_the_iframe_is_always_forced_into_temporary_mode(self): # The embed is a throwaway surface: every chat must be temporary so @@ -162,6 +162,50 @@ def test_the_iframe_is_always_forced_into_temporary_mode(self): self.assertContains(page, "temporary-chat=true") +@patch("chat.config.ENABLED", True) +class ChatWithHandoffTests(TestCase): + """/chat/with/ validates the model, stashes it in the session, + and redirects to /chat/ — where it is consumed exactly once.""" + + def setUp(self): + from infra.tests.factories import ModelConnectionFactory, RegisteredModelFactory + + self.project = ProjectFactory() + user = UserFactory(username="handoff") + MembershipFactory(user=user, project=self.project) + self.client = Client() + self.client.force_login(user) + conn = ModelConnectionFactory(project=self.project, name="OpenAI", + base_url="http://localhost:9999/v1") + self.model = RegisteredModelFactory(connection=conn, project=self.project, + display_name="GPT", model_id="gpt-4o") + + def test_handoff_pins_the_model_for_one_load(self): + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ + patch("chat.config.MODEL", "Qwen3.8-27B"): + resp = self.client.get(f"/chat/with/{self.model.model_id}") + # fetch_redirect_response=False so the redirect isn't followed here + # (following it would consume the one-shot session value). + self.assertRedirects(resp, "/chat/", fetch_redirect_response=False) + page = self.client.get("/chat/") + self.assertContains( + page, 'src="http://127.0.0.1:8801?models=gpt-4o&temporary-chat=true"') + # Consumed: a refresh falls back to the default. + page2 = self.client.get("/chat/") + self.assertContains( + page2, 'src="http://127.0.0.1:8801?models=Qwen3.8-27B&temporary-chat=true"') + + def test_handoff_ignores_a_model_the_user_cannot_see(self): + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ + patch("chat.config.MODEL", "Qwen3.8-27B"): + resp = self.client.get("/chat/with/does-not-exist") + self.assertRedirects(resp, "/chat/", fetch_redirect_response=False) + page = self.client.get("/chat/") + self.assertContains( + page, 'src="http://127.0.0.1:8801?models=Qwen3.8-27B&temporary-chat=true"') + self.assertNotContains(page, "models=does-not-exist") + + @patch("chat.config.ENABLED", True) class ChatModelPickerTests(TestCase): """The top-bar picker offers the user's visible models, grouped by diff --git a/chat/urls.py b/chat/urls.py index 3247476a..2109f793 100644 --- a/chat/urls.py +++ b/chat/urls.py @@ -5,9 +5,10 @@ """ from django.urls import path -from chat.views import ChatView, authz +from chat.views import ChatView, authz, chat_with urlpatterns = [ path("", ChatView.as_view(), name="chat"), + path("with/", chat_with, name="chat_with"), path("authz", authz, name="chat_authz"), ] diff --git a/chat/views.py b/chat/views.py index 1b5c6b39..a90f13a7 100644 --- a/chat/views.py +++ b/chat/views.py @@ -8,6 +8,7 @@ from django.http import Http404, HttpResponse from django.shortcuts import redirect +from django.urls import reverse from django.views.generic import TemplateView # The module, not the names: tests and runtime both read ENABLED/PUBLIC_URL as @@ -33,6 +34,39 @@ def authz(request): return response +def chat_with(request, model_id): + """Hand off from /connections/ to the chat, pinned to one model. + + The icon links here (not straight to /chat/?model=) so the model is + validated server-side and carried in the session, where ChatView consumes + it once. A hand-typed ?model= on /chat/ is ignored — only a model the user + can actually see, chosen through this view, can pin the chat. + """ + if not config.ENABLED: + raise Http404 + if not request.user.is_authenticated: + return redirect(f"/login/?next={request.path}") + model_id = (model_id or "").strip() + if model_id and _user_can_see_model(request, model_id): + request.session["chat_pinned_model"] = model_id + return redirect(reverse("chat")) + + +def _user_can_see_model(request, model_id) -> bool: + """True if the user has a visible, enabled connection serving model_id.""" + from model_registry.services import visible_connections_for + + project = getattr(request, "project", None) + if project is None: + return False + for conn in visible_connections_for(request.user, project): + if not conn.enabled or not (conn.base_url or "").strip(): + continue + if conn.models.filter(enabled=True, model_id=model_id).exists(): + return True + return False + + class ChatView(TemplateView): """The Studio page that embeds Open WebUI.""" @@ -47,10 +81,11 @@ def get(self, request, *args, **kwargs): def get_context_data(self, **kwargs): chat_base = config.public_url(self.request) - # Which model(s) to pin. A ?model= on the Studio page wins over the - # configured default — the /connections "chat" icon links here with the - # model's id, so clicking it opens the chat already on that model. - pinned = (self.request.GET.get("model") or "").strip() or config.MODEL + # Which model(s) to pin. A model chosen through the /connections chat + # icon is stashed in the session by chat_with and consumed here exactly + # once (pop) — so it survives the redirect but not a refresh, and a + # hand-typed ?model= can never pin the chat. Falls back to the default. + pinned = self.request.session.pop("chat_pinned_model", None) or config.MODEL # Shape the embedded chat through URL params, which Open WebUI reads on # load: # ?models= pin to one or more models, comma-separated (the diff --git a/infra/tests/test_connection_sharing.py b/infra/tests/test_connection_sharing.py index c1194760..d1f3d1b8 100644 --- a/infra/tests/test_connection_sharing.py +++ b/infra/tests/test_connection_sharing.py @@ -144,18 +144,18 @@ def test_consumer_sees_shared_connection_readonly(self): self.assertNotContains(page, f'data-conn-open="{self.conn.id}"') self.assertNotContains(page, f'data-discover="{self.conn.id}"') - def test_chat_icon_links_to_chat_with_the_model(self): - # With chat enabled, each model row offers a chat icon that opens - # /chat/ pinned to that model's id. + def test_chat_icon_links_to_the_chat_handoff(self): + # With chat enabled, each model row offers a chat icon that goes + # through /chat/with/ (which validates the model server-side). with mock.patch("chat.config.ENABLED", True): page = self._page_as(self.owner_user, self.owner_ws) - self.assertContains(page, f'/chat/?model={self.model.model_id}') + self.assertContains(page, f'/chat/with/{self.model.model_id}') self.assertContains(page, "Open a chat with") def test_chat_icon_hidden_when_chat_is_disabled(self): with mock.patch("chat.config.ENABLED", False): page = self._page_as(self.owner_user, self.owner_ws) - self.assertNotContains(page, "/chat/?model=") + self.assertNotContains(page, "/chat/with/") def test_owner_sees_edit_controls(self): page = self._page_as(self.owner_user, self.owner_ws) diff --git a/templates/connections.html b/templates/connections.html index 30c271e3..d3b95000 100644 --- a/templates/connections.html +++ b/templates/connections.html @@ -124,7 +124,7 @@

{{ conn.name }}

{% if conn.can_edit %} {% if chat_enabled %} - From b32e295298b03dd1d5ec0e4be21673f3389ad1be Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Wed, 30 Sep 2026 10:22:33 +0200 Subject: [PATCH 27/65] chat: namespace models per connection, drop the hardcoded default model Open WebUI identifies a model by model_id alone, so the same id registered under two connections collides in its model list. Push a prefix_id per connection (keyed on the connection id) so every pushed id is globally unique; Open WebUI strips it before the upstream call. The handoff, picker, iframe ?models= param and saved preference all carry the prefixed id, and the /chat/with/ handoff now takes connection + model. The default model is no longer a config value: the user's saved chat_model preference wins (stale ids dropped), else the first model they can see, else nothing. The picker persists its selection to /me/preferences/. --- chat/api.py | 20 ++++- chat/config.py | 4 - chat/templates/chat/chat.html | 14 ++- chat/tests/test_chat.py | 119 ++++++++++++++++--------- chat/urls.py | 2 +- chat/views.py | 95 ++++++++++++++++---- infra/runs_table.py | 2 +- infra/tests/test_connection_sharing.py | 6 +- templates/connections.html | 2 +- 9 files changed, 193 insertions(+), 71 deletions(-) diff --git a/chat/api.py b/chat/api.py index 36bcfe44..e7a50a51 100644 --- a/chat/api.py +++ b/chat/api.py @@ -149,12 +149,26 @@ def knowledge_base(self, knowledge_id: str) -> dict[str, Any]: # --- pure helpers (no I/O, so they are cheap to test) ---------------------- +def chat_model_prefix(connection) -> str: + """The Open WebUI ``prefix_id`` for a connection. + + Open WebUI identifies a model by ``.`` and strips the + prefix before forwarding the request upstream. Without a prefix, the same + ``model_id`` registered under two different connections collides in Open + WebUI's model list (one silently shadows the other). Keying the prefix on + the connection's primary key makes every pushed model id globally unique + while the upstream request still carries the bare model id. + """ + return str(connection.id) + + def connection_payload(conn) -> dict[str, Any]: """The part of a Studio ModelConnection that Open WebUI needs. ``model_ids`` narrows the connection to the models Studio has registered under it; empty means Studio has registered none, and Open WebUI then offers - whatever the provider lists. + whatever the provider lists. ``prefix_id`` namespaces those ids so the same + model id under two connections does not collide in Open WebUI. """ from model_registry.services import connection_api_key @@ -168,6 +182,7 @@ def connection_payload(conn) -> dict[str, Any]: "model_ids": sorted( conn.models.filter(enabled=True).values_list("model_id", flat=True).distinct() ), + "prefix_id": chat_model_prefix(conn), } @@ -199,6 +214,9 @@ def plan_openai_config(current: dict[str, Any], connections: list[dict[str, Any] "name": connection["name"], # Open WebUI treats an empty list as "no restriction". "model_ids": list(connection.get("model_ids") or []), + # Namespaces the model ids so the same id under two connections + # does not collide in Open WebUI's model list. + "prefix_id": connection.get("prefix_id"), }, ) for connection in connections diff --git a/chat/config.py b/chat/config.py index 87b9c1d4..01807b7d 100644 --- a/chat/config.py +++ b/chat/config.py @@ -53,10 +53,6 @@ def is_disabled(value: str | None) -> bool: #: it is not configured, ``public_url(request)`` derives it from the page's own #: host, because the host has to match for the session cookie to be sent. PUBLIC_URL = (os.environ.get("SIMPLEAUDIT_CHAT_URL") or "").rstrip("/") -#: The model the embedded chat is pinned to. Open WebUI reads it from the -#: ``?model=`` query param on the chat URL, so the iframe opens already on this -#: model and the picker is hidden (see chat/embed.css). Empty means "no pin". -MODEL = (os.environ.get("SIMPLEAUDIT_CHAT_MODEL") or "Qwen3.8-27B").strip() EMAIL_HEADER = "X-Studio-Email" NAME_HEADER = "X-Studio-Name" diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html index 3aa80b1d..31c3a54e 100644 --- a/chat/templates/chat/chat.html +++ b/chat/templates/chat/chat.html @@ -242,6 +242,16 @@ Array.from(picker.querySelectorAll('input[type="checkbox"]:checked')).map(function (c) { return c.value; }) )); } + // Remember the selection so the next visit opens on it. The value is the + // comma-separated selection ("" clears it back to the first available). + function savePreference(models) { + var token = (document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/) || [])[1] || ''; + fetch('/me/preferences/', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': token }, + body: JSON.stringify({ key: 'chat_model', value: models.length ? models.join(',') : null }), + }).catch(function () { /* best-effort; the pin still applies this load */ }); + } function refresh() { var n = selected().length; count.hidden = n === 0; @@ -281,7 +291,9 @@ } picker.addEventListener("change", function () { refresh(); - load(selected()); + var models = selected(); + load(models); + savePreference(models); }); newChat.addEventListener("click", function () { picker.querySelectorAll('input[type="checkbox"]').forEach(function (c) { c.checked = false; }); diff --git a/chat/tests/test_chat.py b/chat/tests/test_chat.py index 77a1e957..164721ae 100644 --- a/chat/tests/test_chat.py +++ b/chat/tests/test_chat.py @@ -100,42 +100,60 @@ def setUp(self): self.client.force_login(user) def test_the_iframe_follows_the_host_in_the_address_bar(self): - with patch("chat.config.PUBLIC_URL", ""), patch("chat.config.PROXY_PORT", 8801), \ - patch("chat.config.MODEL", ""): + with patch("chat.config.PUBLIC_URL", ""), patch("chat.config.PROXY_PORT", 8801): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") self.assertContains(page, 'src="http://127.0.0.1:8801?temporary-chat=true"') page = self.client.get("/chat/", HTTP_HOST="localhost:8000") self.assertContains(page, 'src="http://localhost:8801?temporary-chat=true"') def test_an_explicit_chat_url_always_wins(self): - with patch("chat.config.PUBLIC_URL", "https://chat.example.com"), \ - patch("chat.config.MODEL", ""): + with patch("chat.config.PUBLIC_URL", "https://chat.example.com"): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") self.assertContains(page, 'src="https://chat.example.com?temporary-chat=true"') @patch("chat.config.ENABLED", True) class ChatModelPinTests(TestCase): - """The iframe URL carries ?model= so Open WebUI opens on the pinned model.""" + """The iframe URL carries ?models= so Open WebUI opens on the pinned model. + + The default is the first model the user can see (no hardcoded model), so a + user with a visible connection gets that model pinned; a user with none gets + no pin at all. + """ def setUp(self): + from infra.tests.factories import ModelConnectionFactory, RegisteredModelFactory + self.client = Client() + self.project = ProjectFactory() user = UserFactory(username="pin") - MembershipFactory(user=user, project=ProjectFactory()) + MembershipFactory(user=user, project=self.project) self.client.force_login(user) + # A connection serving a model, so the default resolves to a prefixed + # id (the bare id alone is ambiguous across connections). + self.conn = ModelConnectionFactory(project=self.project, name="OpenAI") + RegisteredModelFactory(connection=self.conn, project=self.project, + display_name="Qwen", model_id="Qwen3.8-27B") def test_pinned_model_is_appended_to_the_iframe_url(self): - with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ - patch("chat.config.MODEL", "Qwen3.8-27B"): + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") - # The & is HTML-escaped to & in the rendered template. + # The & is HTML-escaped to & in the rendered template. The pin + # is the prefixed id, namespaced by the connection that serves it. self.assertContains( - page, 'src="http://127.0.0.1:8801?models=Qwen3.8-27B&temporary-chat=true"') + page, f'src="http://127.0.0.1:8801?models={self.conn.id}.Qwen3.8-27B&temporary-chat=true"') - def test_no_model_param_when_no_model_is_pinned(self): - with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ - patch("chat.config.MODEL", ""): - page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") + def test_no_model_param_when_the_user_has_no_models(self): + # A user with no visible connections has nothing to pin. + from infra.tests.factories import UserFactory + + other = ProjectFactory() + user = UserFactory(username="pin-empty") + MembershipFactory(user=user, project=other) + client = Client() + client.force_login(user) + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"): + page = client.get("/chat/", HTTP_HOST="127.0.0.1:8000") # The iframe src carries no models= (the picker's checkbox values # are value="...", not models=, so this is unambiguous). self.assertContains(page, 'src="http://127.0.0.1:8801?temporary-chat=true"') @@ -144,11 +162,10 @@ def test_no_model_param_when_no_model_is_pinned(self): def test_a_model_query_param_on_chat_is_ignored(self): # A hand-typed ?model= on /chat/ must not pin the chat — only the # /connections handoff (which validates the model) can. - with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ - patch("chat.config.MODEL", "Qwen3.8-27B"): + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"): page = self.client.get("/chat/?model=gpt-4o", HTTP_HOST="127.0.0.1:8000") self.assertContains( - page, 'src="http://127.0.0.1:8801?models=Qwen3.8-27B&temporary-chat=true"') + page, f'src="http://127.0.0.1:8801?models={self.conn.id}.Qwen3.8-27B&temporary-chat=true"') self.assertNotContains(page, "models=gpt-4o") def test_the_iframe_is_always_forced_into_temporary_mode(self): @@ -156,8 +173,7 @@ def test_the_iframe_is_always_forced_into_temporary_mode(self): # nothing accumulates in Open WebUI's history. The New Chat button is # hidden, so a fresh chat only ever starts from a full page load, which # re-reads this param. - with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ - patch("chat.config.MODEL", "Qwen3.8-27B"): + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") self.assertContains(page, "temporary-chat=true") @@ -179,32 +195,54 @@ def setUp(self): base_url="http://localhost:9999/v1") self.model = RegisteredModelFactory(connection=conn, project=self.project, display_name="GPT", model_id="gpt-4o") + # A second model that sorts before "GPT", so the default (first + # available) differs from the handoff pin — the refresh assertion below + # can then tell the one-shot pin apart from the fallback. + self.default_model = RegisteredModelFactory( + connection=conn, project=self.project, + display_name="Alpha", model_id="alpha-1") def test_handoff_pins_the_model_for_one_load(self): - with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ - patch("chat.config.MODEL", "Qwen3.8-27B"): - resp = self.client.get(f"/chat/with/{self.model.model_id}") + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"): + resp = self.client.get(f"/chat/with/{self.model.connection_id}/{self.model.model_id}") # fetch_redirect_response=False so the redirect isn't followed here # (following it would consume the one-shot session value). self.assertRedirects(resp, "/chat/", fetch_redirect_response=False) page = self.client.get("/chat/") + # The pin is the prefixed id (connection id + bare model id). self.assertContains( - page, 'src="http://127.0.0.1:8801?models=gpt-4o&temporary-chat=true"') - # Consumed: a refresh falls back to the default. + page, f'src="http://127.0.0.1:8801?models={self.model.connection_id}.gpt-4o&temporary-chat=true"') + # Consumed: a refresh no longer carries the handoff pin — it falls + # back to the default (the first available model, alpha-1 here). page2 = self.client.get("/chat/") + self.assertNotContains(page2, f"?models={self.model.connection_id}.gpt-4o") self.assertContains( - page2, 'src="http://127.0.0.1:8801?models=Qwen3.8-27B&temporary-chat=true"') + page2, f'src="http://127.0.0.1:8801?models={self.default_model.connection_id}.alpha-1&temporary-chat=true"') def test_handoff_ignores_a_model_the_user_cannot_see(self): - with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ - patch("chat.config.MODEL", "Qwen3.8-27B"): - resp = self.client.get("/chat/with/does-not-exist") + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"): + resp = self.client.get(f"/chat/with/{self.model.connection_id}/does-not-exist") self.assertRedirects(resp, "/chat/", fetch_redirect_response=False) page = self.client.get("/chat/") - self.assertContains( - page, 'src="http://127.0.0.1:8801?models=Qwen3.8-27B&temporary-chat=true"') + # The invalid model is never pinned (the default may still be). self.assertNotContains(page, "models=does-not-exist") + def test_handoff_ignores_a_model_on_a_connection_the_user_cannot_see(self): + # The same bare model id exists on a connection in another workspace; + # pointing the handoff at that connection must not pin it. + from infra.tests.factories import ModelConnectionFactory, RegisteredModelFactory + + other = ProjectFactory() + other_conn = ModelConnectionFactory(project=other, name="Other") + RegisteredModelFactory(connection=other_conn, project=other, + display_name="GPT", model_id="gpt-4o") + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"): + resp = self.client.get(f"/chat/with/{other_conn.id}/{self.model.model_id}") + self.assertRedirects(resp, "/chat/", fetch_redirect_response=False) + page = self.client.get("/chat/") + # The iframe never carries the other connection's prefixed id. + self.assertNotContains(page, f"?models={other_conn.id}.gpt-4o") + @patch("chat.config.ENABLED", True) class ChatModelPickerTests(TestCase): @@ -226,12 +264,14 @@ def test_visible_models_are_listed_and_default_is_checked(self): display_name="Qwen", model_id="Qwen3.8-27B") RegisteredModelFactory(connection=conn, project=self.project, display_name="GPT", model_id="gpt-4o") - with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ - patch("chat.config.MODEL", "Qwen3.8-27B"): + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") - self.assertContains(page, 'value="Qwen3.8-27B" checked') - self.assertContains(page, 'value="gpt-4o"') - self.assertNotContains(page, 'value="gpt-4o" checked') + # Checkbox values are the prefixed ids (connection id + bare model id). + # The default is the first available model, ordered by display name, so + # "GPT" (gpt-4o) sorts before "Qwen" and is pre-checked. + self.assertContains(page, f'value="{conn.id}.gpt-4o" checked') + self.assertContains(page, f'value="{conn.id}.Qwen3.8-27B"') + self.assertNotContains(page, f'value="{conn.id}.Qwen3.8-27B" checked') self.assertContains(page, ">OpenAI<") def test_models_from_other_workspaces_are_hidden(self): @@ -241,8 +281,7 @@ def test_models_from_other_workspaces_are_hidden(self): conn = ModelConnectionFactory(project=other, name="Secret") RegisteredModelFactory(connection=conn, project=other, display_name="Hidden", model_id="hidden-model") - with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ - patch("chat.config.MODEL", ""): + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") self.assertNotContains(page, "hidden-model") self.assertContains(page, "No models yet") @@ -253,8 +292,7 @@ def test_disabled_connection_is_not_offered(self): conn = ModelConnectionFactory(project=self.project, name="Off", enabled=False) RegisteredModelFactory(connection=conn, project=self.project, display_name="Dead", model_id="dead-model") - with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ - patch("chat.config.MODEL", ""): + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") self.assertNotContains(page, "dead-model") @@ -264,8 +302,7 @@ def test_search_box_and_backdrop_are_rendered(self): conn = ModelConnectionFactory(project=self.project, name="OpenAI") RegisteredModelFactory(connection=conn, project=self.project, display_name="Qwen", model_id="Qwen3.8-27B") - with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"), \ - patch("chat.config.MODEL", ""): + with patch("chat.config.PUBLIC_URL", "http://127.0.0.1:8801"): page = self.client.get("/chat/", HTTP_HOST="127.0.0.1:8000") # The filter input and the click-outside backdrop are present. self.assertContains(page, 'id="model-picker-search"') diff --git a/chat/urls.py b/chat/urls.py index 2109f793..286337d4 100644 --- a/chat/urls.py +++ b/chat/urls.py @@ -9,6 +9,6 @@ urlpatterns = [ path("", ChatView.as_view(), name="chat"), - path("with/", chat_with, name="chat_with"), + path("with//", chat_with, name="chat_with"), path("authz", authz, name="chat_authz"), ] diff --git a/chat/views.py b/chat/views.py index a90f13a7..3605fa80 100644 --- a/chat/views.py +++ b/chat/views.py @@ -34,37 +34,48 @@ def authz(request): return response -def chat_with(request, model_id): +def chat_with(request, connection_id, model_id): """Hand off from /connections/ to the chat, pinned to one model. The icon links here (not straight to /chat/?model=) so the model is validated server-side and carried in the session, where ChatView consumes it once. A hand-typed ?model= on /chat/ is ignored — only a model the user can actually see, chosen through this view, can pin the chat. + + The model is identified by its connection plus its bare model id, because + the same model id can be registered under more than one connection. The + session stores the prefixed id (``.``) that Open + WebUI expects once Studio pushes a ``prefix_id`` per connection. """ if not config.ENABLED: raise Http404 if not request.user.is_authenticated: return redirect(f"/login/?next={request.path}") model_id = (model_id or "").strip() - if model_id and _user_can_see_model(request, model_id): - request.session["chat_pinned_model"] = model_id + if model_id and _user_can_see_model(request, connection_id, model_id): + request.session["chat_pinned_model"] = f"{connection_id}.{model_id}" return redirect(reverse("chat")) -def _user_can_see_model(request, model_id) -> bool: - """True if the user has a visible, enabled connection serving model_id.""" +def _user_can_see_model(request, connection_id, model_id) -> bool: + """True if the user has a visible, enabled connection serving model_id. + + The connection must be one the user can see in this workspace, enabled, and + actually serving the bare model id — so a hand-typed id for a connection the + user cannot see is rejected even if the bare id exists elsewhere. + """ from model_registry.services import visible_connections_for project = getattr(request, "project", None) if project is None: return False - for conn in visible_connections_for(request.user, project): - if not conn.enabled or not (conn.base_url or "").strip(): - continue - if conn.models.filter(enabled=True, model_id=model_id).exists(): - return True - return False + conn = next( + (c for c in visible_connections_for(request.user, project) if c.id == connection_id), + None, + ) + if conn is None or not conn.enabled or not (conn.base_url or "").strip(): + return False + return conn.models.filter(enabled=True, model_id=model_id).exists() class ChatView(TemplateView): @@ -84,8 +95,9 @@ def get_context_data(self, **kwargs): # Which model(s) to pin. A model chosen through the /connections chat # icon is stashed in the session by chat_with and consumed here exactly # once (pop) — so it survives the redirect but not a refresh, and a - # hand-typed ?model= can never pin the chat. Falls back to the default. - pinned = self.request.session.pop("chat_pinned_model", None) or config.MODEL + # hand-typed ?model= can never pin the chat. Otherwise the user's saved + # preference, or the first model they can see. + pinned = self.request.session.pop("chat_pinned_model", None) or self._resolve_default_model() # Shape the embedded chat through URL params, which Open WebUI reads on # load: # ?models= pin to one or more models, comma-separated (the @@ -110,14 +122,36 @@ def get_context_data(self, **kwargs): **kwargs, ) - def _chat_model_groups(self): - """The models the top-bar picker offers, grouped by connection. + def _visible_models(self): + """The models the user can chat with, as ``{"id", "name", "has_key"}``. Same visibility rule as the experiments page: this workspace's own - connections plus any shared into it. Each value is the raw model_id — - that is what Open WebUI's ?model=/ ?models= params expect for - OpenAI-compatible connections. + connections plus any shared into it, each enabled and reachable. Each + id is the prefixed id (``.``) — that is what + Open WebUI's ?model=/ ?models= params expect once Studio pushes a + ``prefix_id`` per connection, and it is what disambiguates the same + model id under two connections. """ + from chat.api import chat_model_prefix + from model_registry.services import visible_connections_for + + project = getattr(self.request, "project", None) + if project is None: + return [] + models = [] + for conn in visible_connections_for(self.request.user, project): + if not conn.enabled or not (conn.base_url or "").strip(): + continue + prefix = chat_model_prefix(conn) + models.extend( + {"id": f"{prefix}.{m.model_id}", "name": m.display_name, "has_key": m.has_key} + for m in conn.models.filter(enabled=True) + ) + return models + + def _chat_model_groups(self): + """The models the top-bar picker offers, grouped by connection.""" + from chat.api import chat_model_prefix from model_registry.services import visible_connections_for project = getattr(self.request, "project", None) @@ -127,10 +161,33 @@ def _chat_model_groups(self): for conn in visible_connections_for(self.request.user, project): if not conn.enabled or not (conn.base_url or "").strip(): continue + prefix = chat_model_prefix(conn) models = [ - {"id": m.model_id, "name": m.display_name, "has_key": m.has_key} + {"id": f"{prefix}.{m.model_id}", "name": m.display_name, "has_key": m.has_key} for m in conn.models.filter(enabled=True) ] if models: groups.append({"connection": conn.name, "models": models}) return groups + + def _resolve_default_model(self): + """The model(s) to pin when no model was chosen through the handoff. + + The user's saved preference wins if it still names models they can see + (a connection may have been deleted or a model removed since) — the + saved value is the comma-separated selection, and any ids that are no + longer available are dropped. Otherwise the first available model is + pinned, so the chat opens on something usable rather than an empty + picker. If the user has no visible models at all, nothing is pinned — + Open WebUI shows its own default. + """ + available = self._visible_models() + if not available: + return "" + available_ids = {m["id"] for m in available} + saved = (self.request.user.preferences or {}).get("chat_model") + if saved: + saved_ids = [i for i in (s.strip() for s in saved.split(",")) if i in available_ids] + if saved_ids: + return ",".join(saved_ids) + return available[0]["id"] diff --git a/infra/runs_table.py b/infra/runs_table.py index edefbca8..a6d9c6f3 100644 --- a/infra/runs_table.py +++ b/infra/runs_table.py @@ -38,7 +38,7 @@ } # Preference keys the UI may store (value size is capped). -PREFERENCE_KEYS = {"dashboard_columns"} +PREFERENCE_KEYS = {"dashboard_columns", "chat_model"} MAX_PREFERENCE_BYTES = 20_000 diff --git a/infra/tests/test_connection_sharing.py b/infra/tests/test_connection_sharing.py index d1f3d1b8..988238d5 100644 --- a/infra/tests/test_connection_sharing.py +++ b/infra/tests/test_connection_sharing.py @@ -146,10 +146,12 @@ def test_consumer_sees_shared_connection_readonly(self): def test_chat_icon_links_to_the_chat_handoff(self): # With chat enabled, each model row offers a chat icon that goes - # through /chat/with/ (which validates the model server-side). + # through /chat/with// (which validates the model + # server-side). The connection id disambiguates the same model id + # registered under more than one connection. with mock.patch("chat.config.ENABLED", True): page = self._page_as(self.owner_user, self.owner_ws) - self.assertContains(page, f'/chat/with/{self.model.model_id}') + self.assertContains(page, f'/chat/with/{self.conn.id}/{self.model.model_id}') self.assertContains(page, "Open a chat with") def test_chat_icon_hidden_when_chat_is_disabled(self): diff --git a/templates/connections.html b/templates/connections.html index d3b95000..893d7c8c 100644 --- a/templates/connections.html +++ b/templates/connections.html @@ -124,7 +124,7 @@

{{ conn.name }}

{% if conn.can_edit %} {% if chat_enabled %} -
From 8c3abe96c2613e7d374fe54bc82730eff3329541 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Wed, 30 Sep 2026 12:47:45 +0200 Subject: [PATCH 28/65] chat: forward refused WebSocket upgrades as HTTP errors The proxy piped the upstream's handshake reply straight through, so a refused upgrade (a 401 when trusted-header auth is not applied to the Socket.IO path, a 500, ...) reached the browser as raw bytes dressed up as WebSocket frames and failed opaquely. The handshake reply is now read first: only a 101 is piped, anything else is forwarded as a plain HTTP response (the upstream's own status and safe headers when well-formed, a clean 502 otherwise. Bytes the upstream sent alongside the 101 are replayed before the pipe starts so nothing is lost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> EOF ) --- chat/proxy.py | 91 ++++++++++++++++++++++++++-- chat/tests/test_proxy.py | 127 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 4 deletions(-) diff --git a/chat/proxy.py b/chat/proxy.py index 0b799818..9978119f 100644 --- a/chat/proxy.py +++ b/chat/proxy.py @@ -200,7 +200,9 @@ def _tunnel(self, identity: dict[str, str]) -> None: Nothing here understands WebSocket framing: once the upstream has agreed to the upgrade, the two sockets carry bytes in both directions until one of them closes. The identity headers go on the handshake, which is the - only part Open WebUI authenticates. + only part Open WebUI authenticates. The handshake reply is checked + first: only a 101 is piped, anything else is forwarded as a plain HTTP + error so the browser sees a real response, not raw bytes. """ upstream_url = urlsplit(chat.UPSTREAM) host, port = upstream_url.hostname or "127.0.0.1", upstream_url.port or 80 @@ -228,17 +230,63 @@ def _tunnel(self, identity: dict[str, str]) -> None: client = self.connection try: upstream.sendall(request.encode("latin-1")) + # Read the handshake reply before piping anything. The upgrade is + # only a WebSocket once the upstream answers 101; any other status + # (a 401 when trusted-header auth is not applied to the Socket.IO + # path, a 500, ...) is an ordinary HTTP error that must be + # forwarded as one — piping it would hand the browser raw error + # bytes dressed up as WebSocket frames. + header, leftover = _read_headers(upstream) + status = _status_code(header) + if status != 101: + self._forward_handshake_error(header, status, leftover) + return + # The browser is still waiting for its own handshake reply, so the + # 101 goes to it first. The read above may also have swallowed the + # first frame bytes along with the headers; hand those back to the + # pipe so nothing is lost. + client.sendall(header) upstream.settimeout(None) client.settimeout(None) pump = threading.Thread(target=_pipe, args=(client, upstream), daemon=True) pump.start() - _pipe(upstream, client) + _pipe(upstream, client, leftover) pump.join(timeout=1) except OSError: pass # either side hung up; nothing to salvage finally: upstream.close() + def _forward_handshake_error(self, header: bytes, status: int, + body: bytes = b"") -> None: + """The upstream refused the upgrade: answer the browser with a real + HTTP response instead of piping the error as if it were a WebSocket. + + The upstream's own status and safe headers are forwarded when it + answered with a well-formed response; otherwise a clean 502 stands in + so the client always gets something it can parse. ``body`` is the part + of the error body already read off the socket; the connection-close + delimits it, so the upstream's Content-Length is not forwarded. + """ + try: + if status is None: + self.send_error(502, "Chat backend unavailable") + return + self.send_response(status) + for line in header.split(b"\r\n")[1:]: + if not line or b":" not in line: + continue + name, _, value = line.partition(b":") + if name.strip().lower() in _STRIP_FROM_RESPONSE: + continue + self.send_header(name.decode("latin-1"), value.strip().decode("latin-1")) + self.send_header("Connection", "close") + self.end_headers() + if body: + self.wfile.write(body) + except OSError: + pass # the browser already went away + def _identify(self, cookie: str) -> dict[str, str] | None: """Who is this browser? None when signed out. Cached for IDENTITY_TTL.""" hit, cached = _cached_identity(cookie) @@ -264,9 +312,44 @@ def _identify(self, cookie: str) -> dict[str, str] | None: do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = _proxy -def _pipe(source: socket.socket, destination: socket.socket) -> None: - """Copy bytes one way until the source closes, then half-close the other end.""" +def _read_headers(upstream: socket.socket) -> tuple[bytes, bytes]: + """Read the upstream's handshake reply up to the first blank line. + + Returns (header_bytes, leftover): the leftover is anything read past the + ``\\r\\r\\n`` terminator (the first frame bytes, when the upstream sent + them in the same packet) and must be replayed before the pipe starts. + """ + buffer = b"" + while b"\r\n\r\n" not in buffer: + chunk = upstream.recv(65536) + if not chunk: + break + buffer += chunk + index = buffer.find(b"\r\n\r\n") + if index < 0: + return buffer, b"" + return buffer[: index + 4], buffer[index + 4:] + + +def _status_code(header: bytes) -> int | None: + """The status code of a response head, or None when it is not a response.""" + line = header.split(b"\r\n", 1)[0] + parts = line.split(b" ", 2) + if len(parts) < 2 or not parts[1].isdigit(): + return None + return int(parts[1]) + + +def _pipe(source: socket.socket, destination: socket.socket, + initial: bytes = b"") -> None: + """Copy bytes one way until the source closes, then half-close the other end. + + ``initial`` is data already read off the source (the bytes that followed + the handshake headers) and goes out first, so nothing is lost. + """ try: + if initial: + destination.sendall(initial) while True: chunk = source.recv(65536) if not chunk: diff --git a/chat/tests/test_proxy.py b/chat/tests/test_proxy.py index f54e85db..0190a46c 100644 --- a/chat/tests/test_proxy.py +++ b/chat/tests/test_proxy.py @@ -4,8 +4,10 @@ SIMPLEAUDIT_LOCAL_SQLITE=1 uv run manage.py test chat.tests.test_proxy """ import json +import socket import threading import time +from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from unittest.mock import patch @@ -75,6 +77,46 @@ def do_GET(self): self.wfile.write(body) +class _StubWebSocketUpstream(BaseHTTPRequestHandler): + """Answers a WebSocket upgrade, then plays the scripted reply. + + ``reply`` is the exact byte string sent back to the handshake: a 101 + switch (optionally with first frame bytes in the same packet) or an + ordinary HTTP error. After a 101 it echoes everything it receives, so a + tunnel that works in one direction works in both. + """ + + protocol_version = "HTTP/1.1" + reply = b"" + saw_identity = False + + def log_message(self, *args): + pass + + def do_GET(self): + if (self.headers.get("Upgrade") or "").lower() != "websocket": + self.send_error(400) + return + type(self).saw_identity = ( + self.headers.get(config.EMAIL_HEADER) == "ada@example.com" + ) + self.wfile.write(type(self).reply) + self.wfile.flush() + if type(self).reply.startswith(b"HTTP/1.1 101"): + self._echo() + + def _echo(self): + try: + while True: + chunk = self.connection.recv(65536) + if not chunk: + break + self.wfile.write(chunk) + self.wfile.flush() + except OSError: + pass + + def _serve(handler): server = ThreadingHTTPServer(("127.0.0.1", 0), handler) server.daemon_threads = True @@ -209,3 +251,88 @@ def test_the_embed_stylesheet_comes_from_studio_not_the_upstream(self): self.assertIn("text/css", response.headers["Content-Type"]) self.assertIn("#sidebar", response.text) self.assertNotIn("email", response.text) # not the upstream's echo + + @contextmanager + def _tunnel_upstream(self, reply: bytes): + """A fresh stub upstream that answers the upgrade with ``reply``.""" + _StubWebSocketUpstream.reply = reply + _StubWebSocketUpstream.saw_identity = False + server = _serve(_StubWebSocketUpstream) + with patch.object(config, "UPSTREAM", + f"http://127.0.0.1:{server.server_address[1]}"): + yield server + server.shutdown() + + def test_a_refused_upgrade_comes_back_as_an_http_error_not_a_websocket(self): + """A 401 from the upstream must reach the browser as a 401. + + Before the fix the raw error bytes were piped as if they were + WebSocket frames, and the browser failed opaquely. + """ + reply = ( + b"HTTP/1.1 401 Unauthorized\r\n" + b"Content-Type: text/plain\r\n" + b"Content-Length: 13\r\n" + b"\r\n" + b"unauthorized\n" + ) + with self._tunnel_upstream(reply): + response = httpx.get( + f"{self.url}/ws", + headers={ + "Cookie": VALID_COOKIE, + "Connection": "Upgrade", + "Upgrade": "websocket", + "Sec-WebSocket-Version": "13", + "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", + }, + timeout=10, + ) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.text, "unauthorized\n") + self.assertTrue(_StubWebSocketUpstream.saw_identity) + + def test_a_successful_upgrade_is_piped_both_ways_without_losing_bytes(self): + """A 101 is tunnelled, and bytes sent with the 101 are not lost. + + The stub answers the handshake and, in the same packet, sends the + first "frame" bytes; the tunnel must deliver them, and must carry + bytes back the other way too. + """ + first = b"hello-from-upstream" + reply = ( + b"HTTP/1.1 101 Switching Protocols\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n" + b"\r\n" + ) + first + with self._tunnel_upstream(reply), socket.create_connection( + ("127.0.0.1", self.server.server_address[1]), timeout=10 + ) as client: + client.sendall( + b"GET /ws HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"Connection: Upgrade\r\n" + b"Upgrade: websocket\r\n" + b"Sec-WebSocket-Version: 13\r\n" + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + b"Cookie: " + VALID_COOKIE.encode() + b"\r\n" + b"\r\n" + ) + # The 101 header, then the first bytes the stub sent with it. + buffer = b"" + while not buffer.endswith(first): + chunk = client.recv(65536) + self.assertTrue(chunk, "the tunnel closed before the first bytes") + buffer += chunk + self.assertIn(b"101 Switching Protocols", buffer) + self.assertTrue(buffer.endswith(first)) + + # Client to upstream: the stub echoes it straight back. + client.sendall(b"ping-from-client") + buffer = b"" + while b"ping-from-client" not in buffer: + chunk = client.recv(65536) + self.assertTrue(chunk, "the tunnel closed before the echo") + buffer += chunk + self.assertTrue(_StubWebSocketUpstream.saw_identity) From 6f5cd791db8eb2b7b1ea60f8f313bc93fb02f000 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Wed, 30 Sep 2026 12:48:36 +0200 Subject: [PATCH 29/65] chat: warn on unregistered model pins, and align provider merge by index The ?models= pin only takes effect when the id exactly matches a model Open WebUI knows; otherwise the chat silently falls back to its default model. OpenAI-compatible providers register models by the id their /v1/models endpoint returns, which can differ from Studio's model_id, so a mismatch is easy to create and invisible at chat time. push_now now lists the registered models and logs a warning naming the connection and the missing ids. Reconciliation is a diagnostic: it never raises, and a models-endpoint failure just skips it. plan_openai_config now pairs the parallel URL/key/config lists strictly by index over the union of indices present in any of the three, instead of padding keys to the URL length. A hand edit or a partially failed write can leave the lists out of sync; the old padding could attach one provider's key to another provider's URL. Entries without a base URL are dropped, since a URL is what makes a provider usable. Also split NoAdminError out of ChatAPIError: a missing superuser is a misconfiguration, not a transient state, so the background worker logs it loudly with an actionable message instead of a quiet skip. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- chat/api.py | 47 ++++++++++++++--- chat/sync.py | 59 +++++++++++++++++++++- chat/tests/test_api.py | 109 ++++++++++++++++++++++++++++++++++++++++ chat/tests/test_sync.py | 99 ++++++++++++++++++++++++++++++++++++ 4 files changed, 306 insertions(+), 8 deletions(-) diff --git a/chat/api.py b/chat/api.py index e7a50a51..478ca421 100644 --- a/chat/api.py +++ b/chat/api.py @@ -147,6 +147,22 @@ def knowledge_base(self, knowledge_id: str) -> dict[str, Any]: ] return summary + def list_models(self) -> list[str]: + """Every model id Open WebUI currently registers, as plain strings. + + This is the id space the ``?models=`` pin is checked against: Open + WebUI only pins a model whose id exactly matches one of these. For an + OpenAI-compatible provider these are the ids the upstream ``/v1/models`` + endpoint returns, which may differ from Studio's own ``model_id``. + """ + payload = self.request("GET", "/api/v1/models/") + models = payload.get("models") if isinstance(payload, dict) else payload + return [ + str(model["id"]) + for model in (models or []) + if isinstance(model, dict) and model.get("id") + ] + # --- pure helpers (no I/O, so they are cheap to test) ---------------------- def chat_model_prefix(connection) -> str: @@ -192,17 +208,36 @@ def plan_openai_config(current: dict[str, Any], connections: list[dict[str, Any] Entries Studio pushed before (they carry ``STUDIO_MARKER``) are replaced; entries somebody added in Open WebUI itself are kept, in their order, with their config re-keyed to their new index. + + Open WebUI stores the three structures as parallel, index-aligned lists, + but a hand edit or a partially failed write can leave them out of sync. + The merge therefore pairs strictly by index over the union of the indices + present in any of the three: a missing key defaults to ``""`` and a + missing config to ``{}``, so a key can never end up attached to a URL + from a different index. An entry is kept only if it has a base URL — a + bare key or config with no URL is dropped, since a URL is what makes a + provider usable. """ urls = list(current.get("OPENAI_API_BASE_URLS") or []) keys = list(current.get("OPENAI_API_KEYS") or []) configs = dict(current.get("OPENAI_API_CONFIGS") or {}) - keys += [""] * (len(urls) - len(keys)) - kept = [ - (url, keys[index], configs.get(str(index), {})) - for index, url in enumerate(urls) - if STUDIO_MARKER not in configs.get(str(index), {}) - ] + indices = set(range(len(urls))) | set(range(len(keys))) + for config_key in configs: + if config_key.isdigit() and str(int(config_key)) == config_key: + indices.add(int(config_key)) + + kept = [] + for index in sorted(indices): + url = urls[index] if index < len(urls) else None + if url is None: + continue # a bare key or config with no URL is not a usable provider + api_key = keys[index] if index < len(keys) else "" + raw_config = configs.get(str(index)) + config = raw_config if isinstance(raw_config, dict) else {} + if STUDIO_MARKER in config: + continue + kept.append((url, api_key, config)) ours = [ ( connection["base_url"], diff --git a/chat/sync.py b/chat/sync.py index e49f1f26..e7d658d9 100644 --- a/chat/sync.py +++ b/chat/sync.py @@ -23,6 +23,16 @@ logger = logging.getLogger(__name__) + +class NoAdminError(ChatAPIError): + """No Studio superuser exists to act as the Open WebUI admin. + + Unlike a transient ``ChatAPIError`` (Open WebUI down or still starting), + this is a misconfiguration: every push will keep failing until a + superuser is created, so callers should log it loudly. + """ + + #: How long to wait for more changes before pushing. A save is rarely alone: #: the connections page writes a connection and its models in one go. DELAY_SECONDS = float(os.environ.get("SIMPLEAUDIT_CHAT_SYNC_DELAY", "2")) @@ -42,13 +52,49 @@ def connections_to_push() -> list[dict]: def push_now(payloads: list[dict] | None = None) -> dict[str, int]: """Push connections (all of them by default). Raises ChatAPIError on refusal.""" api = ChatAPI.as_user(_admin()) - result = api.push_connections(connections_to_push() if payloads is None else payloads) + pushed = connections_to_push() if payloads is None else payloads + result = api.push_connections(pushed) # Studio never serves Ollama; leaving it on costs a failing request per page # load and an empty section in Open WebUI's settings. api.disable_ollama() + _reconcile_model_ids(api, pushed) return result +def _reconcile_model_ids(api: ChatAPI, pushed: list[dict]) -> None: + """Warn when a pinned Studio model id is not one Open WebUI registers. + + The ``?models=`` pin only takes effect when the id exactly matches a model + Open WebUI knows; otherwise the chat silently falls back to its default + model. OpenAI-compatible providers register models by the id their + ``/v1/models`` endpoint returns, which can differ from Studio's + ``model_id`` — so a mismatch is easy to create and invisible at chat time. + Never raises: reconciliation is a diagnostic, not part of the push. + """ + wanted = [ + (payload["name"], payload["model_ids"]) + for payload in pushed + if payload.get("model_ids") + ] + if not wanted: + return + try: + registered = set(api.list_models()) + except ChatAPIError as exc: + logger.info("Chat model id reconciliation skipped: %s", exc) + return + for name, model_ids in wanted: + missing = [model_id for model_id in model_ids if model_id not in registered] + if missing: + logger.warning( + "Chat model pin will not work for connection '%s': Studio model " + "id(s) %s are not registered in Open WebUI (the provider " + "returned different ids), so the chat will fall back to the " + "default model.", + name, ", ".join(missing), + ) + + def schedule_push(reason: str = "") -> None: """Push soon, once, in the background. Never raises.""" if not config.ENABLED: @@ -68,6 +114,15 @@ def _run(reason: str) -> None: _timer = None try: result = push_now() + except NoAdminError: + # A real misconfiguration, not a transient state: every push will keep + # failing until someone creates a superuser, so say so loudly. + logger.warning( + "Chat model sync failed (%s): no Studio superuser exists to act as " + "the Open WebUI admin — create a superuser, then run " + "`manage.py sync_chat_models`", + reason or "change", + ) except ChatAPIError as exc: # Expected while Open WebUI is still starting, or when it is not running # at all. Not worth a traceback. @@ -87,5 +142,5 @@ def _admin(): user = User.objects.filter(is_superuser=True).order_by("id").first() if user is None: - raise ChatAPIError("No superuser to act as; Open WebUI's provider config needs an admin.") + raise NoAdminError("No superuser to act as; Open WebUI's provider config needs an admin.") return user diff --git a/chat/tests/test_api.py b/chat/tests/test_api.py index 5ef50ba9..775f914c 100644 --- a/chat/tests/test_api.py +++ b/chat/tests/test_api.py @@ -75,6 +75,98 @@ def test_shorter_key_list_does_not_lose_urls(self): self.assertEqual(len(planned["OPENAI_API_BASE_URLS"]), 2) self.assertEqual(planned["OPENAI_API_KEYS"], ["only-one", ""]) + def _assert_aligned(self, planned): + """The three structures are the same length and keyed by the same indices.""" + urls = planned["OPENAI_API_BASE_URLS"] + keys = planned["OPENAI_API_KEYS"] + configs = planned["OPENAI_API_CONFIGS"] + self.assertEqual(len(urls), len(keys)) + self.assertEqual(len(urls), len(configs)) + self.assertEqual(set(configs), {str(index) for index in range(len(urls))}) + for index, url in enumerate(urls): + self.assertIsInstance(configs[str(index)], dict) + self.assertIsInstance(keys[index], str) + self.assertIn(url, urls) + + def test_orphan_key_beyond_the_url_list_is_dropped_not_mispaired(self): + current = { + "OPENAI_API_BASE_URLS": ["https://a.example/v1"], + "OPENAI_API_KEYS": ["key-a", "orphan-key"], + "OPENAI_API_CONFIGS": {"0": {"enable": True}}, + } + planned = plan_openai_config(current, [ + {"id": 1, "name": "Ours", "base_url": "https://ours.example/v1", + "api_key": "ours", "enabled": True}, + ]) + self.assertEqual(planned["OPENAI_API_BASE_URLS"], + ["https://a.example/v1", "https://ours.example/v1"]) + # The orphan key must not ride along on the second URL. + self.assertEqual(planned["OPENAI_API_KEYS"], ["key-a", "ours"]) + self.assertNotIn("orphan-key", planned["OPENAI_API_KEYS"]) + self.assertEqual(planned["OPENAI_API_CONFIGS"]["0"], {"enable": True}) + self.assertEqual(planned["OPENAI_API_CONFIGS"]["1"][STUDIO_MARKER], 1) + self._assert_aligned(planned) + + def test_config_index_without_a_url_is_dropped(self): + current = { + "OPENAI_API_BASE_URLS": ["https://a.example/v1"], + "OPENAI_API_KEYS": ["key-a"], + "OPENAI_API_CONFIGS": {"0": {"enable": True}, "5": {"enable": True}}, + } + planned = plan_openai_config(current, [ + {"id": 2, "name": "Ours", "base_url": "https://ours.example/v1", + "api_key": "ours", "enabled": True}, + ]) + self.assertEqual(planned["OPENAI_API_BASE_URLS"], + ["https://a.example/v1", "https://ours.example/v1"]) + self.assertEqual(planned["OPENAI_API_KEYS"], ["key-a", "ours"]) + self.assertEqual(planned["OPENAI_API_CONFIGS"]["0"], {"enable": True}) + self.assertEqual(planned["OPENAI_API_CONFIGS"]["1"][STUDIO_MARKER], 2) + self._assert_aligned(planned) + + def test_url_without_a_key_gets_an_empty_key_at_its_own_index(self): + current = { + "OPENAI_API_BASE_URLS": ["https://a.example/v1", "https://b.example/v1"], + "OPENAI_API_KEYS": ["key-a"], + "OPENAI_API_CONFIGS": {}, + } + planned = plan_openai_config(current, [ + {"id": 3, "name": "Ours", "base_url": "https://ours.example/v1", + "api_key": "ours", "enabled": True}, + ]) + self.assertEqual(planned["OPENAI_API_BASE_URLS"], + ["https://a.example/v1", "https://b.example/v1", "https://ours.example/v1"]) + # key-a stays with a.example; b.example gets its own empty key, not key-a. + self.assertEqual(planned["OPENAI_API_KEYS"], ["key-a", "", "ours"]) + self.assertEqual(planned["OPENAI_API_CONFIGS"]["0"], {}) + self.assertEqual(planned["OPENAI_API_CONFIGS"]["1"], {}) + self.assertEqual(planned["OPENAI_API_CONFIGS"]["2"][STUDIO_MARKER], 3) + self._assert_aligned(planned) + + def test_aligned_input_with_mixed_entries_merges_as_before(self): + current = { + "OPENAI_API_BASE_URLS": [ + "https://theirs1.example/v1", "https://theirs2.example/v1", "https://old.example/v1", + ], + "OPENAI_API_KEYS": ["k1", "k2", "old"], + "OPENAI_API_CONFIGS": { + "0": {"enable": True}, + "1": {"name": "Theirs 2"}, + "2": {STUDIO_MARKER: 9}, + }, + } + planned = plan_openai_config(current, [ + {"id": 3, "name": "Ours", "base_url": "https://ours.example/v1", + "api_key": "ours", "enabled": True}, + ]) + self.assertEqual(planned["OPENAI_API_BASE_URLS"], + ["https://theirs1.example/v1", "https://theirs2.example/v1", "https://ours.example/v1"]) + self.assertEqual(planned["OPENAI_API_KEYS"], ["k1", "k2", "ours"]) + self.assertEqual(planned["OPENAI_API_CONFIGS"]["0"], {"enable": True}) + self.assertEqual(planned["OPENAI_API_CONFIGS"]["1"], {"name": "Theirs 2"}) + self.assertEqual(planned["OPENAI_API_CONFIGS"]["2"][STUDIO_MARKER], 3) + self._assert_aligned(planned) + class _StubOpenWebUI(BaseHTTPRequestHandler): """Just enough Open WebUI to answer sign-in, config and knowledge.""" @@ -125,6 +217,13 @@ def do_GET(self): "id": "kb1", "name": "Policies", "description": "HR", "files": [{"id": "f1", "meta": {"name": "handbook.pdf"}}], }) + if self.path == "/api/v1/models/": + return self._reply(200, self.state.get("models", { + "models": [ + {"id": "gpt-4o", "object": "model"}, + {"id": "gpt-4o-mini", "object": "model"}, + ], + })) return self._reply(404, {"detail": "nope"}) @@ -183,6 +282,16 @@ def test_knowledge_base_lists_file_names(self): base = self._api(username="reader2").knowledge_base("kb1") self.assertEqual(base["files"], [{"id": "f1", "name": "handbook.pdf"}]) + def test_list_models_returns_the_registered_ids(self): + self.assertEqual( + self._api(username="reader3").list_models(), + ["gpt-4o", "gpt-4o-mini"], + ) + + def test_list_models_copes_with_a_bare_list_payload(self): + _StubOpenWebUI.state["models"] = [{"id": "local-model"}] + self.assertEqual(self._api(username="reader4").list_models(), ["local-model"]) + def test_an_unreachable_open_webui_is_reported_clearly(self): api = ChatAPI({"X-Studio-Email": "x@y.z"}, base_url="http://127.0.0.1:1") with self.assertRaises(ChatAPIError) as caught: diff --git a/chat/tests/test_sync.py b/chat/tests/test_sync.py index 02130b9e..0ab86681 100644 --- a/chat/tests/test_sync.py +++ b/chat/tests/test_sync.py @@ -4,12 +4,14 @@ SIMPLEAUDIT_LOCAL_SQLITE=1 uv run manage.py test chat.tests.test_sync """ import time +from unittest import mock from unittest.mock import patch from django.test import TestCase, TransactionTestCase from chat import sync from chat.api import ChatAPIError +from chat.sync import NoAdminError from infra.tests.factories import ( ModelConnectionFactory, ProjectFactory, @@ -76,6 +78,103 @@ def test_a_chat_that_is_not_answering_does_not_raise(self): self._settle() # the failure is logged, the caller never sees it +class RunTests(TestCase): + """_run is the background worker: it must never raise, and it must tell + a misconfiguration (no superuser) apart from a transient skip.""" + + def test_no_superuser_is_logged_loudly_with_an_actionable_message(self): + with patch.object( + sync, "push_now", + side_effect=NoAdminError("No superuser to act as; Open WebUI's provider config needs an admin."), + ), self.assertLogs("chat.sync", level="WARNING") as captured: + sync._run("test") + self.assertTrue( + any("no Studio superuser" in line for line in captured.output), + captured.output, + ) + self.assertTrue( + any("manage.py sync_chat_models" in line for line in captured.output), + captured.output, + ) + + def test_a_transient_failure_is_a_quiet_skip_not_a_no_superuser_warning(self): + with patch.object(sync, "push_now", side_effect=ChatAPIError("not running")), \ + self.assertLogs("chat.sync", level="INFO") as captured: + sync._run("test") + self.assertTrue( + any("Chat model sync skipped" in line and "not running" in line for line in captured.output), + captured.output, + ) + self.assertFalse( + any("no Studio superuser" in line for line in captured.output), + captured.output, + ) + + +class ReconcileModelIdsTests(TestCase): + """push_now warns when a pinned Studio model id is not one Open WebUI + registers — the case where the ?models= pin would silently no-op.""" + + def _push(self, registered, model_ids): + api = mock.Mock() + api.push_connections.return_value = {"pushed": 1, "kept": 0} + api.list_models.return_value = registered + with patch.object(sync, "ChatAPI") as chat_api, \ + patch.object(sync, "_admin", return_value=object()): + chat_api.as_user.return_value = api + sync.push_now([ + {"id": 1, "name": "OpenAI", "base_url": "https://api.openai.com/v1", + "api_key": "sk-x", "enabled": True, "model_ids": model_ids}, + ]) + return api + + def test_a_missing_model_id_is_logged_loudly(self): + with self.assertLogs("chat.sync", level="WARNING") as captured: + api = self._push(["gpt-4o"], ["gpt-4o", "studio-internal-id"]) + self.assertTrue( + any("will not work for connection 'OpenAI'" in line + and "studio-internal-id" in line + for line in captured.output), + captured.output, + ) + api.list_models.assert_called_once() + + def test_all_ids_registered_logs_no_warning(self): + import logging + + records = [] + + class _Capture(logging.Handler): + def emit(self, record): + records.append(record.getMessage()) + + handler = _Capture(level=logging.WARNING) + with mock.patch.object(logging.getLogger("chat.sync"), "handlers", [handler]): + self._push(["gpt-4o", "gpt-4o-mini"], ["gpt-4o", "gpt-4o-mini"]) + self.assertFalse( + any("will not work" in line for line in records), + records, + ) + + def test_a_connection_without_model_ids_is_not_checked(self): + api = self._push(["gpt-4o"], []) + api.list_models.assert_not_called() + + def test_a_models_endpoint_failure_skips_reconciliation(self): + api = mock.Mock() + api.push_connections.return_value = {"pushed": 1, "kept": 0} + api.list_models.side_effect = ChatAPIError("not ready") + with patch.object(sync, "ChatAPI") as chat_api, \ + patch.object(sync, "_admin", return_value=object()): + chat_api.as_user.return_value = api + sync.push_now([ + {"id": 1, "name": "OpenAI", "base_url": "https://api.openai.com/v1", + "api_key": "sk-x", "enabled": True, "model_ids": ["gpt-4o"]}, + ]) + # The push itself still reports success. + self.assertEqual(api.push_connections.call_count, 1) + + class SignalTests(TransactionTestCase): """on_commit means the push waits for the transaction, and skips a rollback. From 2a7804a5b9d5bd1829a11b56d0af8b258f8f681e Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Wed, 30 Sep 2026 12:48:58 +0200 Subject: [PATCH 30/65] chat: show a friendly empty state when the user has no project A user with no project at all got an empty model picker and a dead iframe. The chat page now renders a centered "No models available" state with a link to /connections/ instead, and skips the picker and iframe entirely. A user who has a project but no models yet still gets the normal page: the picker shows its own "No models yet" hint and the iframe loads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- chat/templates/chat/chat.html | 28 ++++++++++++++++- chat/tests/test_chat.py | 59 +++++++++++++++++++++++++++++++++++ chat/views.py | 25 +++++++++++---- 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html index 31c3a54e..21e4d78c 100644 --- a/chat/templates/chat/chat.html +++ b/chat/templates/chat/chat.html @@ -143,6 +143,15 @@ opacity: 1; color: var(--text-strong); border-color: #394052; } iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; } + /* No models to offer: a calm, centered explanation instead of an empty + picker and a dead iframe. Reuses the pill for the call to action. */ + .empty-state { + position: absolute; inset: 0; + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: .75rem; text-align: center; padding: 1rem; + } + .empty-state h1 { margin: 0; font-size: 1.1rem; font-weight: 600; color: var(--text-strong); } + .empty-state p { margin: 0; font-size: .85rem; max-width: 26rem; } @@ -153,6 +162,7 @@ Back to Studio + {% if has_models %}
+ {% else %} + + No models yet — register a connection → + + {% endif %} + {% if has_models %} + {% else %} +
+

No models available

+

{{ no_models_message }}

+ Go to Connections → +
+ {% endif %}