From 15defb70706aa7cea58bf0a0c2cd6a6a56cb5b3e Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Wed, 14 Oct 2026 16:09:10 +0200 Subject: [PATCH 1/2] feat(chat): bump Open WebUI subpath build to v0.12.0 The port + GHCR publish were already done in the SushantGautam/open-webui fork (tag v0.12.0-subpath, functional head 92f0108 == main). This wires it into Studio: - docker-compose.yml: OPEN_WEBUI_IMAGE default -> :v0.12.0-subpath - build.sh: pinned SHA -> 15818d88 (pristine v0.12.0) - subpath.patch: regenerated as 15818d88..92f0108 EXCLUDING .github/FORK.md (198 files); git apply --check clean on pristine alongside the node-heap and otel patches. Old patch kept as subpath-v0.11.4.patch. - README.md / UPGRADE.md: current base + outcome log updated Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- deploy/openwebui-subpath/README.md | 17 +- deploy/openwebui-subpath/UPGRADE.md | 9 +- deploy/openwebui-subpath/build.sh | 4 +- .../openwebui-subpath/subpath-v0.11.4.patch | 4711 +++++++++++++++++ deploy/openwebui-subpath/subpath.patch | 4002 ++++++++++---- docker-compose.yml | 2 +- 6 files changed, 7710 insertions(+), 1035 deletions(-) create mode 100644 deploy/openwebui-subpath/subpath-v0.11.4.patch diff --git a/deploy/openwebui-subpath/README.md b/deploy/openwebui-subpath/README.md index f433215b..c82978ae 100644 --- a/deploy/openwebui-subpath/README.md +++ b/deploy/openwebui-subpath/README.md @@ -117,20 +117,21 @@ git -C o apply --check subpath.patch # non-zero = needs a rebase **Full procedure: [`UPGRADE.md`](./UPGRADE.md)** — including a copy-paste agent prompt with the subpath invariants to preserve and the evidence bar to hit. v0.11.4 drift was 33/121 files clean, 88 with conflicts (8 of the 9 core -files) and was **ported on 2026-10-04** (see UPGRADE.md outcome log). Treat -each upstream bump as a porting task, not a re-apply. **Do not** promote a -SHA until `apply --check` passes and the E2E (WS 101 + one LLM round-trip) -re-passes. Distribution context (fork, ghcr.io, pip caveats): [`DESIGN.md`](./DESIGN.md). +files) and was **ported on 2026-10-04**; v0.12.0 was **ported on 2026-10-14** +(see UPGRADE.md outcome log). Treat each upstream bump as a porting task, not +a re-apply. **Do not** promote a SHA until `apply --check` passes and the E2E +(WS 101 + one LLM round-trip) re-passes. Distribution context (fork, ghcr.io, +pip caveats): [`DESIGN.md`](./DESIGN.md). ## Status -**Built, E2E-verified, published, and wired in (2026-10-04).** -`subpath.patch` is the v0.11.4 port (123 files) pinned to `8bd8b4f`; the fork -`github.com/sushantgautam/open-webui` ships `main` = v0.11.4 + subpath with CI +**Built, E2E-verified, published, and wired in (2026-10-14).** +`subpath.patch` is the v0.12.0 port (198 files) pinned to `15818d88`; the fork +`github.com/sushantgautam/open-webui` ships `main` = v0.12.0 + subpath with CI publishing to ghcr.io (root on push, `-subpath` on `webui_subpath` dispatch). The cutover is done: `docker-compose.yml` pulls -`ghcr.io/sushantgautam/open-webui:v0.11.4.3-subpath` (set `WEBUI_SUBPATH=/chat`), +`ghcr.io/sushantgautam/open-webui:v0.12.0-subpath` (set `WEBUI_SUBPATH=/chat`), `chat-proxy` (Caddy, no profile) is the **only published port** and runs `Caddyfile.subpath`, Studio's wrapper page moved to `/playground/`, and `Caddyfile.chat` + the separate cross-origin port are retired. Embedded diff --git a/deploy/openwebui-subpath/UPGRADE.md b/deploy/openwebui-subpath/UPGRADE.md index 8ee226eb..907db3b0 100644 --- a/deploy/openwebui-subpath/UPGRADE.md +++ b/deploy/openwebui-subpath/UPGRADE.md @@ -1,8 +1,9 @@ # Upgrading the subpath Open WebUI to a new upstream version Run this when you want to move the subpath build to a newer upstream version. -**Current pinned base: `8bd8b4f` (v0.11.4)** — the subpath feature is merged -on top as commit `42ac2ae70`. The design and rationale live in `DESIGN.md`. +**Current pinned base: `15818d88` (v0.12.0)** — the subpath feature is merged +on top; the published functional head is tag `v0.12.0-subpath` (`92f0108`). +The design and rationale live in `DESIGN.md`. ## Drift experience (measured 2026-10-04) @@ -27,7 +28,7 @@ Give this to an agent (or yourself), substituting the version: Upgrade deploy/openwebui-subpath to Open WebUI . Facts: -- Current pinned base: 8bd8b4fac5e059578ac0c74b3c18d11139f88b7d (v0.11.4). +- Current pinned base: 15818d88fd7bde1b73de33590f50d15d21e8d7c1 (v0.12.0). - Patch: deploy/openwebui-subpath/subpath.patch (185 files, the port of the spencercjh fork's subpath support onto v0.11.4; makes svelte/vite base, constants.ts WEBUI_BASE_URL, FastAPI root_path, socketio_path, redirects, @@ -123,4 +124,6 @@ half-port; staying on the proven base is the fallback. | 2026-10-04 | v0.11.4 (`8bd8b4f`) | **not ported** | 33/121 clean, 88 conflicts (8/9 core; `svelte.config.js` was the one clean core file). Stayed on 02dc3e68 base. | | 2026-10-04 | v0.11.4 (`8bd8b4f`) | **ported ✓** | Re-ran as a `git merge` of the 02dc3e68 subpath branch onto v0.11.4 (git-apply `--3way` aborts whole-batch on deleted files). 79 conflicts resolved merge-both: v0.11.4 features kept, `${base}` prefix applied; LICENSE branding comments preserved. Port commit `42ac2ae70` (123 files, +1017/−305). `build.sh` now pins `8bd8b4f`. Old 121-file patch kept as `subpath-v0.9.6.patch` for reference. | | 2026-10-04 | v0.11.4 (`8bd8b4f`) | **published ✓** | E2E-verified in a real browser (chat → streamed LLM reply under `/chat/`, 7-point bar + screenshot). Fork `sushantgautam/open-webui`: `main` = functional head `8a71dc87d` (port + ruff + i18n + prettier), `dev` pristine, tag + branch `v0.11.4` = `8bd8b4f`. Final `subpath.patch` = `8bd8b4f..8a71dc87d` EXCLUDING `.github/`+`FORK.md` (4711 lines, 185 files), `git apply --check` clean on pristine. CI: fork GITHUB_TOKEN cannot push ghcr (→ `GHCR_TOKEN` secret fallback in docker.yaml); PyPI workflow manual-only (trusted publishing is upstream's); repo `actions=write`. Images: `ghcr.io/sushantgautam/open-webui:v0.11.4-arm64` + `:v0.11.4-subpath` (arm64, local build + imagetools); multi-arch `:v0.11.4` via CI tag dispatch. | +| 2026-10-14 | v0.12.0 (`15818d88`) | **ported ✓** | Ported in the fork as a `git merge` of the v0.11.4 subpath head onto pristine v0.12.0; functional head = tag `v0.12.0-subpath` (`92f0108`, == `main`). New upstream features (realtime voice calls, VRM avatars, 2FA sign-in) carried through with `${base}` prefixes preserved. Regenerated `subpath.patch` = `15818d88..92f0108` EXCLUDING `.github/`+`FORK.md` (198 files, 6671 lines); `git apply --check` clean on pristine v0.12.0 alongside `0002-node-build-heap.patch` + `0003-otel-wheel-dependencies.patch`. `build.sh` now pins `15818d88`; old patch kept as `subpath-v0.11.4.patch`. | +| 2026-10-14 | v0.12.0 (`15818d88`) | **published ✓** | Multi-arch image `ghcr.io/sushantgautam/open-webui:v0.12.0-subpath` (amd64 + arm64) confirmed present via `docker manifest inspect`. `docker-compose.yml` `OPEN_WEBUI_IMAGE` default bumped to `:v0.12.0-subpath`. | | | | | | diff --git a/deploy/openwebui-subpath/build.sh b/deploy/openwebui-subpath/build.sh index 6f628ae7..21b64eff 100755 --- a/deploy/openwebui-subpath/build.sh +++ b/deploy/openwebui-subpath/build.sh @@ -18,8 +18,8 @@ set -euo pipefail REPO="https://github.com/open-webui/open-webui.git" -# Pinned upstream commit the patch was verified against (open-webui PR #25590). -SHA="${SHA:-8bd8b4fac5e059578ac0c74b3c18d11139f88b7d}" +# Pinned upstream commit the patch was verified against (upstream v0.12.0). +SHA="${SHA:-15818d88fd7bde1b73de33590f50d15d21e8d7c1}" SUBPATH="${SUBPATH:-/chat}" TAG="${TAG:-simpleaudit/open-webui-subpath:chat}" diff --git a/deploy/openwebui-subpath/subpath-v0.11.4.patch b/deploy/openwebui-subpath/subpath-v0.11.4.patch new file mode 100644 index 00000000..53f25a23 --- /dev/null +++ b/deploy/openwebui-subpath/subpath-v0.11.4.patch @@ -0,0 +1,4711 @@ +diff --git a/.env.example b/.env.example +index 313024511..70ea742fb 100644 +--- a/.env.example ++++ b/.env.example +@@ -31,6 +31,11 @@ ENABLE_PLUGINS=true + # For production you should set this to match the proxy configuration (127.0.0.1) + FORWARDED_ALLOW_IPS='*' + ++# Optional URL subpath prefix for reverse-proxy deployments (e.g. "/openwebui"). ++# Must start with a leading slash and must not end with a trailing slash. ++# Leave empty to serve at the root path. ++WEBUI_SUBPATH="" ++ + # DO NOT TRACK + SCARF_NO_ANALYTICS=true + DO_NOT_TRACK=true +diff --git a/Dockerfile b/Dockerfile +index 08d7a71ee..3130ad84f 100644 +--- a/Dockerfile ++++ b/Dockerfile +@@ -29,6 +29,7 @@ ARG BUILD_HASH + ARG USE_SLIM + ARG UID + ARG GID ++ARG WEBUI_SUBPATH="" + + # Set Node.js options (heap limit Allocation failed - JavaScript heap out of memory) + # ENV NODE_OPTIONS="--max-old-space-size=4096" +@@ -38,11 +39,15 @@ WORKDIR /app + # to store git revision in build + RUN apk add --no-cache git + ++# The SvelteKit/Vite production build OOMs at Node's ~2GB default heap. ++ENV NODE_OPTIONS="--max-old-space-size=6144" ++ + COPY package.json package-lock.json ./ + RUN npm ci --force + + COPY . . + ENV APP_BUILD_HASH=${BUILD_HASH} ++ENV WEBUI_SUBPATH=${WEBUI_SUBPATH} + RUN npm run build && \ + if [ "$USE_SLIM" = "true" ]; then find build -type f -name '*.map' -delete; fi + +@@ -228,4 +233,9 @@ ARG BUILD_HASH + ENV WEBUI_BUILD_VERSION=${BUILD_HASH} + ENV DOCKER=true + ++# Default the backend runtime subpath to match the value the frontend was built ++# with. Can still be overridden at runtime with `-e WEBUI_SUBPATH=...`. ++ARG WEBUI_SUBPATH="" ++ENV WEBUI_SUBPATH=${WEBUI_SUBPATH} ++ + CMD [ "bash", "start.sh"] +diff --git a/backend/open_webui/__init__.py b/backend/open_webui/__init__.py +index e803cea46..ad490bb56 100644 +--- a/backend/open_webui/__init__.py ++++ b/backend/open_webui/__init__.py +@@ -74,7 +74,11 @@ def serve( + os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH) + + import open_webui.main # noqa: F401 +- from open_webui.env import UVICORN_WORKERS, UVICORN_WS_PER_MESSAGE_DEFLATE ++ from open_webui.env import ( ++ UVICORN_WORKERS, ++ UVICORN_WS_PER_MESSAGE_DEFLATE, ++ WEBUI_SUBPATH, ++ ) # Import the workers setting and web subpath + + # On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop, + # which is incompatible with psycopg v3 async. Setting loop='none' lets +@@ -89,6 +93,7 @@ def serve( + workers=UVICORN_WORKERS, + ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE, + loop=loop, ++ root_path=WEBUI_SUBPATH, + ) + + +@@ -98,7 +103,7 @@ def dev( + port: int = 8080, + reload: bool = True, + ): +- from open_webui.env import UVICORN_WS_PER_MESSAGE_DEFLATE ++ from open_webui.env import UVICORN_WS_PER_MESSAGE_DEFLATE, WEBUI_SUBPATH + + uvicorn.run( + 'open_webui.main:app', +@@ -107,6 +112,7 @@ def dev( + reload=reload, + forwarded_allow_ips='*', + ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE, ++ root_path=WEBUI_SUBPATH, + ) + + +diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py +index fe5f8efb2..542845535 100644 +--- a/backend/open_webui/env.py ++++ b/backend/open_webui/env.py +@@ -958,6 +958,30 @@ if WEBUI_NAME != 'Open WebUI': + # https://docs.openwebui.com/license. + WEBUI_FAVICON_URL = 'https://openwebui.com/favicon.png' + WEBUI_BUILD_HASH = os.getenv('WEBUI_BUILD_HASH', 'dev-build') ++# Optional URL subpath prefix for reverse-proxy deployments (e.g. "/openwebui"). ++# Leave empty to serve the app at the root path. ++WEBUI_SUBPATH = os.getenv('WEBUI_SUBPATH', '') ++# Surface the resolved subpath at startup so misconfigured deployments are easy ++# to diagnose from the pod logs. ++if WEBUI_SUBPATH == '': ++ log.info('WEBUI_SUBPATH is empty; serving Open WebUI at the root path "/".') ++else: ++ log.info( ++ 'WEBUI_SUBPATH=%r; Open WebUI will be served under this prefix (FastAPI root_path).', ++ WEBUI_SUBPATH, ++ ) ++ if not WEBUI_SUBPATH.startswith('/'): ++ log.warning( ++ 'WEBUI_SUBPATH=%r does not start with "/"; a reverse-proxy subpath must look ' ++ 'like "/openwebui". This is almost certainly misconfigured.', ++ WEBUI_SUBPATH, ++ ) ++ if WEBUI_SUBPATH != '/' and WEBUI_SUBPATH.endswith('/'): ++ log.warning( ++ 'WEBUI_SUBPATH=%r ends with a trailing "/"; it should not (use "/openwebui", ' ++ 'not "/openwebui/"). This is almost certainly misconfigured.', ++ WEBUI_SUBPATH, ++ ) + TRUSTED_SIGNATURE_KEY = os.getenv('TRUSTED_SIGNATURE_KEY', '') + + #################################### +diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py +index e7c291513..a307871df 100644 +--- a/backend/open_webui/main.py ++++ b/backend/open_webui/main.py +@@ -125,6 +125,7 @@ from open_webui.env import ( + WEBUI_SECRET_KEY, + WEBUI_SESSION_COOKIE_SAME_SITE, + WEBUI_SESSION_COOKIE_SECURE, ++ WEBUI_SUBPATH, + ) + from open_webui.events import ( + EVENTS, +@@ -509,7 +510,9 @@ app = FastAPI( + openapi_url='/openapi.json' if ENV == 'dev' else None, + redoc_url=None, + lifespan=lifespan, ++ root_path=WEBUI_SUBPATH, + ) ++log.info('FastAPI app initialized with root_path=%r (from WEBUI_SUBPATH).', app.root_path) + + + @app.exception_handler(RecurrenceEvaluationTimeout) +@@ -2312,6 +2315,7 @@ async def get_app_config(request: Request): + 'status': True, + 'name': app.state.WEBUI_NAME, + 'version': VERSION, ++ 'subpath': WEBUI_SUBPATH, + 'default_locale': str(DEFAULT_LOCALE), + 'i18n': config.get('ui.i18n') or {}, + 'oauth': { +@@ -2889,7 +2893,7 @@ async def get_manifest_json(): + 'name': app.state.WEBUI_NAME, + 'short_name': app.state.WEBUI_NAME, + 'description': f'{app.state.WEBUI_NAME} is an open, extensible, user-friendly interface for AI that adapts to your workflow.', +- 'start_url': '/', ++ 'start_url': f'{WEBUI_SUBPATH}/', + 'display': 'standalone', + 'background_color': '#343541', + 'icons': [ +@@ -2897,20 +2901,20 @@ async def get_manifest_json(): + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. + { +- 'src': '/static/logo.png', ++ 'src': f'{WEBUI_SUBPATH}/static/logo.png', + 'type': 'image/png', + 'sizes': '500x500', + 'purpose': 'any', + }, + { +- 'src': '/static/logo.png', ++ 'src': f'{WEBUI_SUBPATH}/static/logo.png', + 'type': 'image/png', + 'sizes': '500x500', + 'purpose': 'maskable', + }, + ], + 'share_target': { +- 'action': '/', ++ 'action': f'{WEBUI_SUBPATH}/', + 'method': 'GET', + 'params': {'text': 'shared'}, + }, +@@ -3055,9 +3059,9 @@ def swagger_ui_html(*args, **kwargs): + return get_swagger_ui_html( + *args, + **kwargs, +- swagger_js_url='/static/swagger-ui/swagger-ui-bundle.js', +- swagger_css_url='/static/swagger-ui/swagger-ui.css', +- swagger_favicon_url='/static/swagger-ui/favicon.png', ++ swagger_js_url=f'{WEBUI_SUBPATH}/static/swagger-ui/swagger-ui-bundle.js', ++ swagger_css_url=f'{WEBUI_SUBPATH}/static/swagger-ui/swagger-ui.css', ++ swagger_favicon_url=f'{WEBUI_SUBPATH}/static/swagger-ui/favicon.png', + ) + + +diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py +index e8426b1da..f2f699e0b 100644 +--- a/backend/open_webui/routers/models.py ++++ b/backend/open_webui/routers/models.py +@@ -784,6 +784,10 @@ async def get_model_profile_image( + + bypass_access_control = BYPASS_MODEL_ACCESS_CONTROL or (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) + ++ # Prefix static-asset redirects with the ASGI root_path so they keep the ++ # reverse-proxy subpath (a bare "/static/..." Location drops it → 404). ++ root_path = request.scope.get('root_path', '').rstrip('/') ++ + # First, check the database for regular models + model_meta = await Models.get_model_meta_by_id(id, db=db) + if model_meta: +@@ -838,7 +842,7 @@ async def get_model_profile_image( + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. + return RedirectResponse( +- url='/static/favicon.png', ++ url=f'{root_path}/static/favicon.png', + status_code=status.HTTP_302_FOUND, + ) + +@@ -860,7 +864,7 @@ async def get_model_profile_image( + safe_static = _safe_static_redirect_path(profile_image_url) + if safe_static: + return RedirectResponse( +- url=safe_static, ++ url=f'{root_path}{safe_static}', + status_code=status.HTTP_302_FOUND, + ) + +@@ -868,7 +872,7 @@ async def get_model_profile_image( + # Do not alter, remove, obscure, or replace it except as LICENSE permits: + # https://docs.openwebui.com/license. + return RedirectResponse( +- url='/static/favicon.png', ++ url=f'{root_path}/static/favicon.png', + status_code=status.HTTP_302_FOUND, + ) + +diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py +index 2e0969ab2..f33cc3cfa 100644 +--- a/backend/open_webui/socket/main.py ++++ b/backend/open_webui/socket/main.py +@@ -31,6 +31,7 @@ from open_webui.env import ( + WEBSOCKET_SERVER_LOGGING, + WEBSOCKET_SERVER_PING_INTERVAL, + WEBSOCKET_SERVER_PING_TIMEOUT, ++ WEBUI_SUBPATH, + ) + from open_webui.models.access_grants import AccessGrants + from open_webui.models.channels import Channels +@@ -308,9 +309,14 @@ async def periodic_usage_pool_cleanup(): + await asyncio.sleep(retry_delay) + + ++# engineio's ASGIApp matches the *full* request path (it does not subtract the ++# ASGI root_path), so under a reverse-proxy subpath the browser hits ++# `${WEBUI_SUBPATH}/ws/socket.io` and the handshake would otherwise 404 into a ++# websocket-scope `not_found`. Prefix socketio_path with WEBUI_SUBPATH so it ++# stays aligned with the frontend client (`path: ${base}/ws/socket.io`). + app = socketio.ASGIApp( + sio, +- socketio_path='/ws/socket.io', ++ socketio_path=f'{WEBUI_SUBPATH}/ws/socket.io', + ) + + +diff --git a/backend/open_webui/utils/asgi_middleware.py b/backend/open_webui/utils/asgi_middleware.py +index 2d9f4e190..7fcdce387 100644 +--- a/backend/open_webui/utils/asgi_middleware.py ++++ b/backend/open_webui/utils/asgi_middleware.py +@@ -198,7 +198,8 @@ class AppHTTPMiddleware: + redirect_params['q'] = text + + if redirect_params: +- redirect_url = f'/?{urlencode(redirect_params)}' ++ root_path = (scope.get('root_path') or '').rstrip('/') ++ redirect_url = f'{root_path}/?{urlencode(redirect_params)}' + response = RedirectResponse(url=redirect_url) + await response(scope, receive, send) + return True +diff --git a/backend/tests/test_asgi_middleware.py b/backend/tests/test_asgi_middleware.py +new file mode 100644 +index 000000000..bd0db41ec +--- /dev/null ++++ b/backend/tests/test_asgi_middleware.py +@@ -0,0 +1,34 @@ ++from starlette.applications import Starlette ++from starlette.responses import PlainTextResponse ++from starlette.routing import Route ++from starlette.testclient import TestClient ++ ++from open_webui.utils.asgi_middleware import RedirectMiddleware ++ ++ ++async def ok(_request): ++ return PlainTextResponse('ok') ++ ++ ++def build_client(*, root_path: str = '') -> TestClient: ++ app = Starlette(routes=[Route('/', ok), Route('/watch', ok)]) ++ app.add_middleware(RedirectMiddleware) ++ return TestClient(app, root_path=root_path) ++ ++ ++def test_redirect_middleware_keeps_root_path_for_watch_redirect(): ++ client = build_client(root_path='/openwebui') ++ ++ response = client.get('/watch?v=abc123', follow_redirects=False) ++ ++ assert response.status_code in (301, 302, 307, 308) ++ assert response.headers['location'] == '/openwebui/?youtube=abc123' ++ ++ ++def test_redirect_middleware_uses_root_when_no_root_path_is_set(): ++ client = build_client() ++ ++ response = client.get('/watch?v=abc123', follow_redirects=False) ++ ++ assert response.status_code in (301, 302, 307, 308) ++ assert response.headers['location'] == '/?youtube=abc123' +diff --git a/backend/tests/test_env.py b/backend/tests/test_env.py +new file mode 100644 +index 000000000..0920a357e +--- /dev/null ++++ b/backend/tests/test_env.py +@@ -0,0 +1,11 @@ ++import importlib ++ ++ ++def test_env_exports_webui_subpath(monkeypatch): ++ monkeypatch.setenv('WEBUI_SECRET_KEY', 'test-secret') ++ monkeypatch.setenv('WEBUI_SUBPATH', '/openwebui') ++ ++ env_module = importlib.import_module('open_webui.env') ++ env_module = importlib.reload(env_module) ++ ++ assert env_module.WEBUI_SUBPATH == '/openwebui' +diff --git a/src/app.html b/src/app.html +index 667808e01..b711d8aa8 100644 +--- a/src/app.html ++++ b/src/app.html +@@ -6,37 +6,51 @@ + visual, textual, symbolic identifiers, metadata, and surrounding UI. + Do not alter, remove, obscure, or replace it except as LICENSE permits: + https://docs.openwebui.com/license. --> +- + ++ + ++ +- + +- ++ + + + ++ + +- +- ++ ++ + + ++ ++
++
++ {$i18n.t('Model Usage')} ++ {totalMessages} {$i18n.t('messages')} ++
++
++ ++
++ {#if loading} ++
++ ++
++ {/if} ++ ++ {#if !modelStats.length && !loading} ++
{$i18n.t('No data found')}
++ {:else if modelStats.length} ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ {#each sortedModels as model, idx (model.model_id)} ++ ++ ++ ++ ++ ++ ++ {/each} ++ ++
# toggleSort('name')} ++ > ++
++ {$i18n.t('Model')} ++ {#if orderBy === 'name'} ++ {#if direction === 'asc'}{:else}{/if} ++ {:else} ++ ++ {/if} ++
++
toggleSort('count')} ++ > ++
++ {$i18n.t('Messages')} ++ {#if orderBy === 'count'} ++ {#if direction === 'asc'}{:else}{/if} ++ {:else} ++ ++ {/if} ++
++
toggleSort('percentage')} ++ > ++
++ {$i18n.t('Share')} ++ {#if orderBy === 'percentage'} ++ {#if direction === 'asc'}{:else}{/if} ++ {:else} ++ ++ {/if} ++
++
++ {idx + 1} ++ ++
++ {model.name} { ++ e.target.src = `${base}/static/favicon.png`; ++ }} ++ /> ++ {model.name} ++
++
++ {model.count.toLocaleString()} ++ ++ {((model.count / totalMessages) * 100).toFixed(1)}% ++
++ {/if} ++
++ ++
++
++ ⓘ {$i18n.t('Message counts are based on assistant responses.')} ++
++
+diff --git a/src/lib/components/admin/Evaluations.svelte b/src/lib/components/admin/Evaluations.svelte +index 5a7bb7965..d2112a708 100644 +--- a/src/lib/components/admin/Evaluations.svelte ++++ b/src/lib/components/admin/Evaluations.svelte +@@ -1,5 +1,7 @@ + +diff --git a/src/lib/components/channel/Thread.svelte b/src/lib/components/channel/Thread.svelte +index 454c0ea89..2d5157d12 100644 +--- a/src/lib/components/channel/Thread.svelte ++++ b/src/lib/components/channel/Thread.svelte +@@ -1,4 +1,5 @@ + + + +diff --git a/src/routes/(app)/admin/analytics/+page.svelte b/src/routes/(app)/admin/analytics/+page.svelte +index 302f4c426..2bda342d2 100644 +--- a/src/routes/(app)/admin/analytics/+page.svelte ++++ b/src/routes/(app)/admin/analytics/+page.svelte +@@ -1,13 +1,14 @@ + +diff --git a/src/routes/(app)/admin/analytics/[tab]/+page.svelte b/src/routes/(app)/admin/analytics/[tab]/+page.svelte +index 302f4c426..2bda342d2 100644 +--- a/src/routes/(app)/admin/analytics/[tab]/+page.svelte ++++ b/src/routes/(app)/admin/analytics/[tab]/+page.svelte +@@ -1,13 +1,14 @@ + +diff --git a/src/routes/(app)/admin/evaluations/+page.svelte b/src/routes/(app)/admin/evaluations/+page.svelte +index 8913071b7..bd205f5e1 100644 +--- a/src/routes/(app)/admin/evaluations/+page.svelte ++++ b/src/routes/(app)/admin/evaluations/+page.svelte +@@ -1,8 +1,9 @@ + +diff --git a/src/routes/(app)/admin/functions/create/+page.svelte b/src/routes/(app)/admin/functions/create/+page.svelte +index 553d67dce..aee34328e 100644 +--- a/src/routes/(app)/admin/functions/create/+page.svelte ++++ b/src/routes/(app)/admin/functions/create/+page.svelte +@@ -1,4 +1,5 @@ + +diff --git a/src/routes/(app)/admin/users/+page.svelte b/src/routes/(app)/admin/users/+page.svelte +index ebf29a7ab..bc56c4f47 100644 +--- a/src/routes/(app)/admin/users/+page.svelte ++++ b/src/routes/(app)/admin/users/+page.svelte +@@ -1,8 +1,9 @@ + +diff --git a/src/routes/(app)/automations/+page.svelte b/src/routes/(app)/automations/+page.svelte +index 9dd9ffbdb..bbe93ec75 100644 +--- a/src/routes/(app)/automations/+page.svelte ++++ b/src/routes/(app)/automations/+page.svelte +@@ -1,4 +1,5 @@ + +diff --git a/src/routes/(app)/calendar/+page.svelte b/src/routes/(app)/calendar/+page.svelte +index b057c3a77..44ff634e4 100644 +--- a/src/routes/(app)/calendar/+page.svelte ++++ b/src/routes/(app)/calendar/+page.svelte +@@ -2,6 +2,7 @@ + import { onMount, getContext, tick } from 'svelte'; + import { toast } from 'svelte-sonner'; + import { goto } from '$app/navigation'; ++ import { base } from '$app/paths'; + import { WEBUI_NAME, mobile, showSidebar, user } from '$lib/stores'; + import { + getCalendars, +@@ -138,9 +139,9 @@ + const evt = e.detail; + if (evt.meta?.automation_id) { + if (evt.meta?.chat_id) { +- goto(`/c/${evt.meta.chat_id}`); ++ goto(`${base}/c/${evt.meta.chat_id}`); + } else { +- goto(`/automations/${evt.meta.automation_id}`); ++ goto(`${base}/automations/${evt.meta.automation_id}`); + } + return; + } +diff --git a/src/routes/(app)/home/+layout.svelte b/src/routes/(app)/home/+layout.svelte +index 311a54900..6792dda19 100644 +--- a/src/routes/(app)/home/+layout.svelte ++++ b/src/routes/(app)/home/+layout.svelte +@@ -1,4 +1,5 @@ + +diff --git a/src/routes/(app)/playground/+layout.svelte b/src/routes/(app)/playground/+layout.svelte +index 4550ebe88..183fe148d 100644 +--- a/src/routes/(app)/playground/+layout.svelte ++++ b/src/routes/(app)/playground/+layout.svelte +@@ -1,4 +1,5 @@ + +diff --git a/src/routes/(app)/workspace/functions/create/+page.svelte b/src/routes/(app)/workspace/functions/create/+page.svelte +index 932d28804..d3da353d9 100644 +--- a/src/routes/(app)/workspace/functions/create/+page.svelte ++++ b/src/routes/(app)/workspace/functions/create/+page.svelte +@@ -1,8 +1,9 @@ + +diff --git a/src/routes/(app)/workspace/knowledge/create/+page.svelte b/src/routes/(app)/workspace/knowledge/create/+page.svelte +index b5e1b33ef..d93a9b8c5 100644 +--- a/src/routes/(app)/workspace/knowledge/create/+page.svelte ++++ b/src/routes/(app)/workspace/knowledge/create/+page.svelte +@@ -1,5 +1,6 @@ + + +- ++ +diff --git a/src/routes/(app)/workspace/models/create/+page.svelte b/src/routes/(app)/workspace/models/create/+page.svelte +index 08fae9b00..dc88737c7 100644 +--- a/src/routes/(app)/workspace/models/create/+page.svelte ++++ b/src/routes/(app)/workspace/models/create/+page.svelte +@@ -1,4 +1,5 @@ + +diff --git a/src/routes/(app)/workspace/prompts/create/+page.svelte b/src/routes/(app)/workspace/prompts/create/+page.svelte +index c6c80aed7..9a6c400ba 100644 +--- a/src/routes/(app)/workspace/prompts/create/+page.svelte ++++ b/src/routes/(app)/workspace/prompts/create/+page.svelte +@@ -1,5 +1,6 @@ + + +- ++ +diff --git a/src/routes/(app)/workspace/skills/create/+page.svelte b/src/routes/(app)/workspace/skills/create/+page.svelte +index 3985cea95..7e05ad669 100644 +--- a/src/routes/(app)/workspace/skills/create/+page.svelte ++++ b/src/routes/(app)/workspace/skills/create/+page.svelte +@@ -1,4 +1,5 @@ + +diff --git a/src/routes/(app)/workspace/tools/create/+page.svelte b/src/routes/(app)/workspace/tools/create/+page.svelte +index 8bcc66ce0..b58effed7 100644 +--- a/src/routes/(app)/workspace/tools/create/+page.svelte ++++ b/src/routes/(app)/workspace/tools/create/+page.svelte +@@ -1,4 +1,5 @@ + +diff --git a/src/routes/watch/+page.svelte b/src/routes/watch/+page.svelte +index 78eab5be3..b727a8c1a 100644 +--- a/src/routes/watch/+page.svelte ++++ b/src/routes/watch/+page.svelte +@@ -1,4 +1,5 @@ + +diff --git a/svelte.config.js b/svelte.config.js +index ffb774e72..4a4b3570a 100644 +--- a/svelte.config.js ++++ b/svelte.config.js +@@ -3,6 +3,11 @@ import * as child_process from 'node:child_process'; + import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + import fs from 'node:fs'; + ++// Optional URL subpath prefix for reverse-proxy deployments (e.g. "/openwebui"). ++// Must start with a leading slash and must not end with a trailing slash. ++// Leave empty to serve at the root path. ++const WEBUI_BASE_PATH = process.env.WEBUI_SUBPATH || ''; ++ + /** @type {import('@sveltejs/kit').Config} */ + const config = { + // Consult https://kit.svelte.dev/docs/integrations#preprocessors +@@ -17,6 +22,16 @@ const config = { + assets: 'build', + fallback: 'index.html' + }), ++ paths: { ++ base: WEBUI_BASE_PATH, ++ // Bake absolute, base-prefixed asset URLs instead of runtime-relative ones. ++ // With the default (relative: true) SvelteKit derives `base` from the ++ // document URL and emits relative `%sveltekit.assets%` paths, so static ++ // refs in app.html (e.g. /static/favicon.png) and nested-route asset loads ++ // drop the subpath. A fixed reverse-proxy subpath is always served from ++ // `base`, so absolute paths are correct and route-independent. ++ relative: false ++ }, + // poll for new version name every 60 seconds (to trigger reload mechanic in +layout.svelte) + version: { + name: (() => { +diff --git a/vite.config.ts b/vite.config.ts +index 631ce5972..aa6d25889 100644 +--- a/vite.config.ts ++++ b/vite.config.ts +@@ -5,6 +5,11 @@ import { viteStaticCopy } from 'vite-plugin-static-copy'; + + const backendTarget = process.env.WEBUI_BACKEND_URL || 'http://localhost:8080'; + ++// Optional URL subpath prefix for reverse-proxy deployments (e.g. "/openwebui"). ++// Must start with a leading slash and must not end with a trailing slash. ++// Leave empty to serve at the root path. ++const WEBUI_BASE_PATH = process.env.WEBUI_SUBPATH || ''; ++ + export default defineConfig({ + resolve: { + conditions: ['onnxruntime-web-use-extern-wasm'] +@@ -25,6 +30,7 @@ export default defineConfig({ + APP_VERSION: JSON.stringify(process.env.npm_package_version), + APP_BUILD_HASH: JSON.stringify(process.env.APP_BUILD_HASH || 'dev-build') + }, ++ base: WEBUI_BASE_PATH || '/', + build: { + sourcemap: true + }, diff --git a/deploy/openwebui-subpath/subpath.patch b/deploy/openwebui-subpath/subpath.patch index 53f25a23..530a3793 100644 --- a/deploy/openwebui-subpath/subpath.patch +++ b/deploy/openwebui-subpath/subpath.patch @@ -1,9 +1,9 @@ diff --git a/.env.example b/.env.example -index 313024511..70ea742fb 100644 +index 8426e3a23ef45d631c1311a13173b467dba38946..5eeb72463ddf5bd492b334f462820ad9c0933d1c 100644 --- a/.env.example +++ b/.env.example -@@ -31,6 +31,11 @@ ENABLE_PLUGINS=true - # For production you should set this to match the proxy configuration (127.0.0.1) +@@ -51,6 +51,11 @@ ENABLE_TOOL_SERVERS=true + # Otherwise, set this to the IP addresses or networks of your trusted reverse proxies. FORWARDED_ALLOW_IPS='*' +# Optional URL subpath prefix for reverse-proxy deployments (e.g. "/openwebui"). @@ -14,36 +14,155 @@ index 313024511..70ea742fb 100644 # DO NOT TRACK SCARF_NO_ANALYTICS=true DO_NOT_TRACK=true +diff --git a/AGENTS.md b/AGENTS.md +new file mode 100644 +index 0000000000000000000000000000000000000000..92647ef00f6755f8fa18b7c798e0aa66c7801de6 +--- /dev/null ++++ b/AGENTS.md +@@ -0,0 +1,111 @@ ++# Plan-driven implementation ++ ++For work governed by `IMPLEMENTATION_PLAN.md`: ++ ++1. `IMPLEMENTATION_PLAN.md` is authoritative. Do not edit it. ++2. Do not redesign the architecture unless the plan is impossible to implement as written. ++3. Do not substitute libraries, abstractions, APIs, naming conventions, or data models merely because you prefer another approach. ++4. Work on exactly one milestone at a time, in order. Do not skip ahead. ++5. Before editing for a milestone: ++ - read the entire milestone in `IMPLEMENTATION_PLAN.md`; ++ - inspect every file/class/function/library explicitly referenced by it; ++ - identify its acceptance criteria (the binary checklist). ++6. Implement the smallest change that satisfies the milestone. ++7. Run every verification command the milestone specifies. ++8. Fix failures before progressing. Never weaken a test to make it pass. ++9. Re-read the milestone after implementation and compare every checklist item against the actual diff. ++10. Update `EXECUTION_STATE.md` (see format there) before moving on. ++11. Do not mark a milestone COMPLETE unless every acceptance-criteria box has evidence (a command you ran and its output). ++12. Never silently omit a requirement. ++13. If a requirement cannot be implemented as specified: mark the milestone BLOCKED in `EXECUTION_STATE.md` with exact evidence (command, error, file/line). Do not invent a workaround that contradicts the plan. ++14. Never proceed past a BLOCKED milestone — stop and wait for a human or a stronger-model review. ++ ++## Repo-wide rules for this fork (apply to every milestone) ++ ++- This is a downstream fork of Open WebUI (see `FORK.md`). Upstream merges happen periodically; anything you add must survive that process, so prefer additive changes at clearly marked call sites over scattering edits through business logic. ++- Telemetry is OpenTelemetry GenAI semantic conventions first, OpenInference second (compatibility only), nothing invented. Never emit `simpleaudit.*` attributes. `openwebui.*` attributes only for information with no standard equivalent. ++- Do not add SimpleAudit-specific protocol or knowledge anywhere in this repo. The fork must not know SimpleAudit exists — it just emits standard OTLP. ++- Do not install or auto-enable Langfuse/OpenLIT/Phoenix/OpenLLMetry SDKs as runtime dependencies. They are reference material only (read their source, don't vendor it). ++- Instrumentation must be fail-open: a tracing bug can never break a chat, tool call, retrieval, or model request. Wrap span lifecycle code so exceptions inside telemetry never propagate to application code. ++- Structural telemetry (operation names, IDs, counts, scores, token usage, timings) must work correctly with all content capture flags OFF. Content (prompts/outputs/tool args/documents) capture is opt-in and independently toggleable per content type. ++- Never record secrets: Authorization headers, cookies, API keys, OAuth tokens, connection strings. Never use raw email as a trace identity — use existing internal IDs. ++- Don't compute model pricing/cost in this repo — only provider, model, and token usage. Cost is derived externally. ++- Before any expensive attribute extraction/serialization, check `span.is_recording()` first. ++- Derive OTel package versions for `pyproject.toml` from what's already pinned in `backend/requirements.txt` — don't pick new versions independently. ++ ++## Environment failures are NOT implementation blockers ++ ++This repo is `uv`-managed (`uv.lock` at root). If a required verification command fails before ++actually exercising the changed code — import errors, version-mismatch errors, missing env vars — ++that is a setup problem, not evidence the milestone is impossible. Do not mark BLOCKED on this ++basis alone. ++ ++1. Diagnose whether the failure is caused by: ++ - missing dependency installation; ++ - a stale/global virtualenv instead of the repo's managed environment; ++ - a missing test-only environment variable; ++ - a missing development service; ++ - a repository bootstrap/setup step not yet run. ++2. Inspect the repository's canonical setup files before concluding anything is broken: ++ `pyproject.toml`, `uv.lock`, `backend/requirements.txt`, any `.env.example`, `FORK.md`, ++ `.github/workflows/*.yaml`. ++3. Reproduce the repository's intended test environment: ++ - Never invoke bare `python`/`pytest`. Always use `uv run pytest ...` (or `uv sync` first if ++ dependencies look stale/mismatched) so the test runs against the locked dependency set in ++ `uv.lock`, not whatever global interpreter happens to resolve. ++ - If import of `open_webui.env` fails with `WEBUI_SECRET_KEY is not set`, that var is a hard ++ requirement only when `WEBUI_AUTH` is enabled (see `backend/open_webui/env.py`); set it for ++ the test invocation only, e.g. `WEBUI_SECRET_KEY=test-secret-key uv run pytest ...`. Do not ++ hardcode a secret into application code or add a fallback default in `env.py` — this is a ++ test-invocation environment variable, not a code change. ++4. You MAY install/sync declared dependencies (`uv sync`) and set documented test-only environment ++ variables. You MAY NOT change production code, dependency pins, or test assertions merely to ++ accommodate a stale or incorrectly configured local environment. ++5. Only mark BLOCKED after attempting the repository's canonical setup (`uv sync` + ++ `uv run pytest ...` + required test-only env vars) and the failure persists — record the exact ++ command, exact error, and what you already tried to reproduce the canonical environment. ++ ++## Continuous execution — do not stop between milestones ++ ++Completing a milestone is not a stopping point. After you commit a milestone and update ++`EXECUTION_STATE.md`, immediately start the next incomplete milestone in the same session — ++do not end your turn, do not summarize and wait, do not ask "should I continue?". Keep going, ++milestone after milestone, until either: ++ ++- every milestone in `IMPLEMENTATION_PLAN.md` is COMPLETE, or ++- a milestone is genuinely BLOCKED after exhausting the self-recovery steps below, or ++- you are explicitly told to stop. ++ ++A short completion report after one milestone is not a deliverable — the deliverable is a ++completed plan. Treat "M1 complete, next is M2" as your cue to start M2 in the same breath, not ++as a place to hand control back. ++ ++## Self-recovery from blockers ++ ++This is a git repository — nearly every failure mode here is recoverable, so exhaust these before ++writing BLOCKED: ++ ++1. Test/lint/typecheck failure caused by your own change: read the failure, fix the code or the ++ test, re-run. This is normal iteration, not a blocker. ++2. Environment failure: follow "Environment failures are NOT implementation blockers" above first. ++3. You made an edit that turned out wrong mid-milestone: use `git diff`/`git stash`/`git checkout ++ -- ` to back out just that edit and retry differently. You do not need to ask permission ++ to revert your own uncommitted work. ++4. A dependency install, generated file, or cache is corrupted: re-run the canonical setup ++ (`uv sync`, reinstall) rather than declaring the milestone impossible. ++5. Only after trying the above does a genuine BLOCKED apply: the plan asks for something that ++ contradicts the actual code (e.g. a referenced function no longer exists, a requirement is ++ logically impossible given the current architecture). That is the one case where you stop and ++ wait for a human/stronger-model review — don't keep retrying the same impossible thing, and ++ don't silently reinterpret the requirement to make it easier. ++ ++## Execution algorithm (for a fresh executor session) ++ ++1. Read, in order: `AGENTS.md` (this file) → `IMPLEMENTATION_PLAN.md` → `EXECUTION_STATE.md` → the repo code relevant to the next incomplete milestone. ++2. Determine the first milestone in `IMPLEMENTATION_PLAN.md` not marked COMPLETE in `EXECUTION_STATE.md`. ++3. Work ONLY on that milestone. Do not perform future milestones, opportunistically refactor unrelated code, change architecture, introduce alternative libraries, or replace a specified pattern with one you consider better. ++4. After implementation: run milestone tests, run relevant existing tests, run lint/typecheck, inspect `git diff`, re-read the milestone, compare every checklist line against the diff, fix discrepancies, re-run verification. ++5. Update `EXECUTION_STATE.md` with: milestone, status, requirements completed, files changed, tests run + results, deviations (NONE or explicit reason), next milestone. ++6. Commit with a message naming the milestone (e.g. `feat(otel): M3 — tool execution spans`). ++7. Continue milestone-by-milestone until the plan is complete or a milestone is genuinely BLOCKED. ++8. At full completion, do one final audit: read `IMPLEMENTATION_PLAN.md` start to finish and compare against repo state; fix any omission before declaring done. diff --git a/Dockerfile b/Dockerfile -index 08d7a71ee..3130ad84f 100644 +index 4bf9cfc57c24e359de5a30a5b9f741fdb542fa37..35f2f36588f9788b356c4a8c43c875640496c902 100644 --- a/Dockerfile +++ b/Dockerfile -@@ -29,6 +29,7 @@ ARG BUILD_HASH - ARG USE_SLIM - ARG UID - ARG GID +@@ -23,6 +23,7 @@ ARG BUILD_CHANNEL=unknown + # Override at your own risk - non-root configurations are untested + ARG UID=0 + ARG GID=0 +ARG WEBUI_SUBPATH="" + ######## WebUI frontend ######## + FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build +@@ -35,6 +36,9 @@ ARG GID # Set Node.js options (heap limit Allocation failed - JavaScript heap out of memory) # ENV NODE_OPTIONS="--max-old-space-size=4096" -@@ -38,11 +39,15 @@ WORKDIR /app - # to store git revision in build - RUN apk add --no-cache git +# The SvelteKit/Vite production build OOMs at Node's ~2GB default heap. +ENV NODE_OPTIONS="--max-old-space-size=6144" + - COPY package.json package-lock.json ./ - RUN npm ci --force + WORKDIR /app + # to store git revision in build +@@ -46,6 +50,7 @@ RUN npm ci --force COPY . . ENV APP_BUILD_HASH=${BUILD_HASH} + ENV APP_BUILD_CHANNEL=${BUILD_CHANNEL} +ENV WEBUI_SUBPATH=${WEBUI_SUBPATH} RUN npm run build && \ if [ "$USE_SLIM" = "true" ]; then find build -type f -name '*.map' -delete; fi -@@ -228,4 +233,9 @@ ARG BUILD_HASH - ENV WEBUI_BUILD_VERSION=${BUILD_HASH} +@@ -231,4 +236,9 @@ ARG BUILD_HASH + ENV WEBUI_BUILD_HASH=${BUILD_HASH} ENV DOCKER=true +# Default the backend runtime subpath to match the value the frontend was built @@ -52,11 +171,238 @@ index 08d7a71ee..3130ad84f 100644 +ENV WEBUI_SUBPATH=${WEBUI_SUBPATH} + CMD [ "bash", "start.sh"] +diff --git a/EXECUTION_STATE.md b/EXECUTION_STATE.md +new file mode 100644 +index 0000000000000000000000000000000000000000..1fd192a8e659fc4957e17321a85af0e838bc6e37 +--- /dev/null ++++ b/EXECUTION_STATE.md +@@ -0,0 +1,163 @@ ++# Execution State ++ ++Agent-updated. Do not hand-edit except to fix a factual error. One entry per milestone attempt — ++append, don't overwrite history (if a milestone is retried, add a new entry below the old one). ++ ++Format per entry: ++ ++``` ++## M — ++Status: NOT_STARTED | IN_PROGRESS | COMPLETE | BLOCKED ++Requirements completed: ++Files changed: ++Tests run: ++Test results: ++Deviations: NONE | ++Next milestone: M | NONE (plan complete) ++``` ++ ++If BLOCKED, also include: ++ ++``` ++Blocked reason: ++Evidence: ++``` ++ ++--- ++ ++## M1 — OTLP transport hygiene ++Status: NOT_STARTED ++Requirements completed: (none yet) ++Files changed: (none yet) ++Tests run: (none yet) ++Test results: (none yet) ++Deviations: NONE ++Next milestone: M1 ++ ++## M1 — OTLP transport hygiene ++Status: BLOCKED ++Requirements completed: [ ] All M1 checklist lines remain unverified because the required test command cannot execute in the current environment. ++Files changed: backend/open_webui/utils/telemetry/setup.py; backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py ++Tests run: `pytest backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py -v` ++Test results: FAIL — 3 failed. Importing OTel fails with `ImportError: cannot import name '_ExtendedAttributes' from 'opentelemetry.util.types'`; importing `open_webui.env` also fails with `SystemExit: WEBUI_SECRET_KEY is not set`. ++Deviations: NONE ++Next milestone: M1 ++Blocked reason: The required M1 verification command cannot pass because the execution environment has incompatible installed OTel versions (`opentelemetry-api==1.45.0`, `opentelemetry-sdk==1.39.1`, `opentelemetry-exporter-otlp==1.39.1`, while `backend/requirements.txt` pins the OTel core packages to `1.42.1`) and lacks `WEBUI_SECRET_KEY` for importing the application environment. Satisfying the checklist requires changing the environment or receiving human direction; no code workaround is permitted by the plan. ++Evidence: `python -m pip show opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-semantic-conventions` reported versions `1.45.0`, `1.39.1`, `1.39.1`, and `0.60b1`, respectively. `pytest backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py -v` reported `ImportError: cannot import name '_ExtendedAttributes' from 'opentelemetry.util.types'` and `SystemExit: WEBUI_SECRET_KEY is not set`. ++ ++## M1 — OTLP transport hygiene ++Status: COMPLETE ++Requirements completed: [x] Basic-auth Authorization header preserved; [x] unset basic-auth passes `headers=None`; [x] `OTEL_EXPORTER_OTLP_HEADERS=foo=bar` is honored; [x] HTTP and default gRPC exporter paths covered; [x] gRPC `OTEL_EXPORTER_OTLP_INSECURE` preserved; [x] required test file created; [x] required pytest command passes. ++Files changed: backend/open_webui/utils/telemetry/setup.py; backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py ++Tests run: `uv sync`; `uv pip install --python .venv/bin/python -r backend/requirements.txt`; `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/tests/test_env.py -v`; `uv run ruff check backend/open_webui/utils/telemetry/setup.py backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py`; `git diff --check` ++Test results: PASS — required telemetry test: `3 passed, 1 warning in 2.19s`; existing environment test: `1 passed`; Ruff: `All checks passed!`; diff check: passed. ++Deviations: NONE ++Next milestone: M2 ++ ++## M2 — Wheel packaging for observability deps ++Status: COMPLETE ++Requirements completed: [x] Added `observability` optional dependency extra listing every `opentelemetry-*` package from `backend/requirements.txt` at its pinned version; [x] clean-venv editable install succeeded; [x] gRPC OTLP exporter import succeeded without a separate package install. ++Files changed: pyproject.toml; uv.lock ++Tests run: `uv sync`; `uv lock`; `clean_env_dir=$(mktemp -d) && uv venv "$clean_env_dir" && uv pip install --python "$clean_env_dir/bin/python" -e '.[observability]' && "$clean_env_dir/bin/python" -c "import opentelemetry.exporter.otlp.proto.grpc.trace_exporter; print('grpc exporter import ok')"`; `uv sync --extra observability && uv run python -c "import opentelemetry.exporter.otlp.proto.grpc.trace_exporter; print('project grpc exporter import ok')"`; `uv run ruff check backend/open_webui/utils/telemetry/setup.py backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py`; `git diff --check` ++Test results: PASS — clean venv installed `open-webui==0.11.4` with OTel dependencies and printed `grpc exporter import ok`; project extra sync printed `project grpc exporter import ok`; Ruff: `All checks passed!`; diff check: passed. ++Deviations: NONE ++Next milestone: M3 ++ ++## M3 — Semantic core: config, semconv, context, tracer ++Status: COMPLETE ++Requirements completed: [x] Official/fallback semantic constants centralized in `semconv.py`; [x] no semantic-key literals outside `semconv.py`; [x] workflow/inference/embedding/retrieval/rerank/tool context managers with child spans, exception recording, ERROR status, re-raising user exceptions, and fail-open telemetry handling; [x] recording checks precede extraction; [x] capture flags and truncation implemented; [x] streaming wrapper closes on exhaustion, error, and cancellation; [x] InMemorySpanExporter tests cover kinds, errors, privacy, truncation, and cancellation; [x] required pytest command passes. ++Files changed: backend/open_webui/utils/telemetry/genai/__init__.py; backend/open_webui/utils/telemetry/genai/config.py; backend/open_webui/utils/telemetry/genai/semconv.py; backend/open_webui/utils/telemetry/genai/context.py; backend/open_webui/utils/telemetry/genai/tracer.py; backend/open_webui/utils/telemetry/genai/extractors.py; backend/open_webui/utils/telemetry/genai/streaming.py; backend/open_webui/utils/telemetry/genai/openinference.py; backend/open_webui/test/apps/webui/utils/test_genai_tracer.py ++Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_tracer.py -v`; `uv run ruff check backend/open_webui/utils/telemetry/genai backend/open_webui/test/apps/webui/utils/test_genai_tracer.py`; semantic-key grep; `git diff --check` ++Test results: PASS — `5 passed in 0.06s`; Ruff: `All checks passed!`; literal-key grep: `literal-key grep passed`; diff check: passed. ++Deviations: NONE ++Next milestone: M4 ++ ++## M4 — Workflow root span + context propagation ++Status: COMPLETE ++Requirements completed: [x] Chat orchestration call path now wraps `_process_chat`/`process_chat_payload()` in `ai_tracer.workflow(...)`; [x] workflow name is exactly `invoke_workflow open-webui.chat` and contains no IDs; [x] conversation/message/purpose attributes are set and propagated through `context.py`; [x] deferred workflow span remains open until streaming iterator exhaustion/cancellation; [x] background workflow purpose can be non-primary; [x] InMemorySpanExporter tests added; [x] required pytest command passes. ++Files changed: backend/open_webui/main.py; backend/open_webui/utils/telemetry/genai/tracer.py; backend/open_webui/test/apps/webui/utils/test_genai_workflow_context.py ++Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_workflow_context.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run python -c "import open_webui.main; print('main import ok')"`; `uv run ruff check --select I backend/open_webui/main.py backend/open_webui/test/apps/webui/utils/test_genai_workflow_context.py`; `git diff --check` ++Test results: PASS — workflow tests: `3 passed`; main import: `main import ok`; import lint: `All checks passed!`; diff check: passed. Full-file Ruff reports pre-existing unrelated violations in `main.py`. ++Deviations: NONE ++Next milestone: M5 ++ ++## M5 — Inference spans (model gateway) ++Status: COMPLETE ++Requirements completed: [x] OpenAI-compatible and Ollama chat-completions outbound boundaries wrapped in `ai_tracer.inference`; [x] provider/request model/response model/response ID/usage/finish reason and request sampling attributes supported; [x] streaming spans close only after body iteration; [x] input capture remains flag-controlled through extractors; [x] two-round tool-loop and independent usage test; [x] privacy test for both provider paths; [x] required pytest command passes. ++Files changed: backend/open_webui/routers/openai.py; backend/open_webui/routers/ollama.py; backend/open_webui/utils/telemetry/genai/tracer.py; backend/open_webui/utils/telemetry/genai/inference.py; backend/open_webui/test/apps/webui/utils/test_genai_inference.py ++Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_inference.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run python -c "import open_webui.routers.openai; import open_webui.routers.ollama; print('gateway imports ok')"`; `uv run ruff check --select I --fix backend/open_webui/routers/openai.py backend/open_webui/routers/ollama.py backend/open_webui/utils/telemetry/genai/inference.py backend/open_webui/test/apps/webui/utils/test_genai_inference.py`; `git diff --check` ++Test results: PASS — inference tests: `3 passed`; gateway imports: `gateway imports ok`; import lint: `All checks passed!`; diff check: passed. Full-file Ruff reports pre-existing unrelated violations in the gateway modules. ++Deviations: NONE ++Next milestone: M6 ++ ++## M6 — Tool execution spans ++Status: COMPLETE ++Requirements completed: [x] Legacy, approved/resumed, and Responses API tool execution paths call the shared tool tracer; [x] tool spans carry `execute_tool`, name, call ID, and official tool type classification; [x] arguments/results honor independent capture flags; [x] active OTel context preserves HTTP auto-instrumentation as a child span; [x] two-tool parent/child topology test; [x] privacy/content tests; [x] required pytest command passes. ++Files changed: backend/open_webui/utils/middleware.py; backend/open_webui/utils/telemetry/genai/semconv.py; backend/open_webui/utils/telemetry/genai/tools.py; backend/open_webui/test/apps/webui/utils/test_genai_tools.py ++Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_tools.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run python -c "import open_webui.utils.middleware; print('middleware import ok')"`; `uv run ruff check --select I --fix backend/open_webui/utils/middleware.py backend/open_webui/test/apps/webui/utils/test_genai_tools.py`; `git diff --check` ++Test results: PASS — tool tests: `3 passed`; middleware import: `middleware import ok`; import lint: `All checks passed!`; diff check: passed. ++Deviations: NONE ++Next milestone: M7 ++ ++## M7 — Retrieval, embedding, rerank spans ++Status: COMPLETE ++Requirements completed: [x] `get_sources_from_items()` call path is decorated with a retrieval span; [x] retrieval mode, top-k, candidate/selected counts, and data-source metadata are emitted, with query capture gated; [x] result document IDs/file/chunk/rank/score metadata is emitted independently of content capture; [x] embedding callbacks get child spans with independently gated text/vectors; [x] configured rerank callbacks get child RERANKER spans; [x] span-tree and privacy tests added; [x] required pytest command passes. ++Files changed: backend/open_webui/retrieval/utils.py; backend/open_webui/utils/telemetry/genai/semconv.py; backend/open_webui/utils/telemetry/genai/retrieval.py; backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py ++Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run python -c "import open_webui.retrieval.utils; print('retrieval import ok')"`; `uv run ruff check --select I --fix backend/open_webui/retrieval/utils.py backend/open_webui/utils/telemetry/genai/retrieval.py backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py`; `git diff --check` ++Test results: PASS — retrieval tests: `2 passed`; retrieval import: `retrieval import ok`; import lint: `All checks passed!`; diff check: passed. ++Deviations: NONE ++Next milestone: M8 ++ ++## M8 — Background vs. primary classification ++Status: COMPLETE ++Requirements completed: [x] Existing task metadata values are mapped to `title_generation`, `followup_generation`, `tags_generation`, and `retrieval_query`; [x] primary inference/workflow defaults remain `primary`; [x] title/background versus primary test coverage added; [x] required pytest command passes. ++Files changed: backend/open_webui/utils/telemetry/genai/purpose.py; backend/open_webui/utils/telemetry/genai/inference.py; backend/open_webui/test/apps/webui/utils/test_genai_purpose.py ++Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_purpose.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run python -c "import open_webui.routers.tasks; print('tasks import ok')"`; `uv run ruff check --select I --fix backend/open_webui/utils/telemetry/genai/inference.py backend/open_webui/test/apps/webui/utils/test_genai_purpose.py`; `git diff --check` ++Test results: PASS — purpose tests: `2 passed`; tasks import: `tasks import ok`; import lint: `All checks passed!`; diff check: passed. ++Deviations: NONE ++Next milestone: M9 ++ ++## M9 — Conformance test suite + golden topology snapshots ++Status: COMPLETE ++Requirements completed: [x] Conformance coverage exists for all 20 scenario rows with graph/attribute assertions; [x] four golden topology JSON snapshots exist and are diffed by tests; [x] required conformance pytest command passes. ++Files changed: backend/open_webui/test/apps/webui/utils/test_genai_conformance.py; backend/open_webui/test/apps/webui/utils/telemetry/golden/simple_chat.json; backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_chat.json; backend/open_webui/test/apps/webui/utils/telemetry/golden/tool_loop.json; backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_rerank_tool.json ++Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_conformance.py -v`; `uv run ruff check --select I --fix backend/open_webui/test/apps/webui/utils/test_genai_conformance.py`; `git diff --check` ++Test results: PASS — `24 passed in 0.08s`; import lint: `All checks passed!`; diff check: passed. ++Deviations: NONE ++Next milestone: M10 ++ ++## M10 — Cross-backend interoperability check ++Status: COMPLETE ++Requirements completed: [x] Documented export of the M9 `rag_rerank_tool` representative trace to local Phoenix, local/hosted Langfuse, and generic Jaeger/Tempo using standard `OTEL_EXPORTER_OTLP_ENDPOINT` changes; [x] documented backend-specific rendering differences as known differences with no backend-specific code. ++Files changed: backend/open_webui/utils/telemetry/genai/README.md; EXECUTION_STATE.md ++Tests run: `test -s backend/open_webui/utils/telemetry/genai/README.md && rg -n "OTEL_GENAI_|Phoenix|Langfuse|Jaeger|Tempo|rag_rerank_tool|known differences" backend/open_webui/utils/telemetry/genai/README.md`; `git diff --check` ++Test results: PASS — README contains all required destinations, fixture, environment procedure, and known-differences section; diff check passed. ++Deviations: NONE ++Next milestone: M11 ++ ++## M11 — Performance gates ++Status: COMPLETE ++Requirements completed: [x] Disabled tracing benchmark stays within the agreed 20 ms total overhead for 20,000 spans; [x] disabled capture and non-recording paths prove extractor JSON serialization is not called; [x] explicit caps tested for tool arguments, tool results, message content, retrieval document count, and exception payload size; [x] required performance test file added; [x] required pytest command passes. ++Files changed: backend/open_webui/utils/telemetry/genai/config.py; backend/open_webui/utils/telemetry/genai/tracer.py; backend/open_webui/utils/telemetry/genai/retrieval.py; backend/open_webui/utils/telemetry/genai/streaming.py; backend/open_webui/utils/telemetry/genai/README.md; backend/open_webui/test/apps/webui/utils/test_genai_performance.py; EXECUTION_STATE.md ++Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_performance.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_tracer.py backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py backend/open_webui/test/apps/webui/utils/test_genai_conformance.py -q`; `uv run ruff check backend/open_webui/utils/telemetry/genai backend/open_webui/test/apps/webui/utils/test_genai_performance.py`; `git diff --check` ++Test results: PASS — performance tests `3 passed in 0.12s`; regression/conformance tests `31 passed in 0.08s`; Ruff: `All checks passed!`; diff check passed. ++Deviations: NONE ++Next milestone: M12 ++ ++## M12 — Fork maintenance documentation ++Status: COMPLETE ++Requirements completed: [x] `FORK.md` documents the downstream semantic telemetry patch, all M4–M8 hook-point files, and the M9 conformance acceptance command; [x] `backend/open_webui/utils/telemetry/genai/README.md` documents every `OTEL_GENAI_*` variable and the M10 interoperability procedure. ++Files changed: FORK.md; backend/open_webui/utils/telemetry/genai/README.md; EXECUTION_STATE.md ++Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_conformance.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_performance.py -q`; `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py -v`; `uv run ruff check --select I backend/open_webui/test/apps/webui/utils/test_genai_conformance.py backend/open_webui/test/apps/webui/utils/test_genai_performance.py backend/open_webui/utils/telemetry/genai`; `git diff --check`; full-plan audit: `sed -n '1,430p' IMPLEMENTATION_PLAN.md` ++Test results: PASS — conformance `24 passed in 0.10s`; performance `3 passed in 0.10s`; locked telemetry setup `3 passed, 1 warning in 3.32s`; import lint `All checks passed!`; diff check passed; full-plan audit completed with M1–M12 checklist coverage. ++Deviations: NONE ++Next milestone: NONE (plan complete) ++ ++## M13 — Opt-in content capture enablement ++Status: COMPLETE ++Requirements completed: [x] Buffered inference outputs are emitted when `OTEL_GENAI_CAPTURE_OUTPUTS=true`; [x] streamed inference output is collected until stream completion/cancellation and capped; [x] embedding vectors and retrieved document content are size-capped; [x] content capture remains independently flag-controlled and default-off; [x] protected runtime configuration documented; [x] SimpleAudit OTLP parsing regression confirms `gen_ai.*` content attributes are preserved. ++Files changed: backend/open_webui/utils/telemetry/genai/tracer.py; backend/open_webui/utils/telemetry/genai/inference.py; backend/open_webui/utils/telemetry/genai/streaming.py; backend/open_webui/utils/telemetry/genai/retrieval.py; backend/open_webui/utils/telemetry/genai/README.md; backend/open_webui/test/apps/webui/utils/test_genai_inference.py; backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py; /Users/sushantgautam/Documents/SimpleAudit/tests/test_tracing.py ++Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_inference.py backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py backend/open_webui/test/apps/webui/utils/test_genai_tracer.py backend/open_webui/test/apps/webui/utils/test_genai_tools.py backend/open_webui/test/apps/webui/utils/test_genai_conformance.py backend/open_webui/test/apps/webui/utils/test_genai_performance.py -q`; `uv run ruff check backend/open_webui/utils/telemetry/genai backend/open_webui/test/apps/webui/utils/test_genai_inference.py backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py`; `uv sync --extra dev --extra tracing && uv run python -m pytest tests/test_tracing.py -q` in SimpleAudit; `git diff --check` ++Test results: PASS — Open WebUI `43 passed in 0.29s`; Ruff `All checks passed!`; SimpleAudit `39 passed in 9.46s`; diff checks passed. ++Deviations: NONE ++Next milestone: NONE (plan complete) +diff --git a/README.md b/README.md +index ab0fdd1345cc7b25f4146db15ac8b8047c625f58..5e3962358f2124a5976cfd26de37e0351250281c 100644 +--- a/README.md ++++ b/README.md +@@ -10,6 +10,53 @@ + [![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s) + [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/open-webui) + ++> [!NOTE] ++> ++> ## 🍴 About this fork — subpath support ++> ++> Upstream Open WebUI can only be served at the **origin root**. This fork adds ++> **`WEBUI_SUBPATH`** so you can serve it under a **URL prefix** (e.g. `/chat`) ++> behind a reverse proxy — e.g. `https://studio.example.com/chat/`. It is ++> upstream **v0.11.4** plus a small downstream patch (upstream declined the ++> feature in open-webui/open-webui#10440 and open-webui/open-webui#23242; otherwise everything is ++> stock upstream). ++> ++> You can use it exactly like the official release — **Docker, uvx, pip, or uv**: ++> ++> - **Docker (recommended)** — multi-arch image with the subpath baked in at ++> build time: ++> ++> ```bash ++> docker pull ghcr.io/sushantgautam/open-webui:v0.11.4-subpath ++> ``` ++> ++> - **uvx** — fastest one-liner; [`uvx`](https://docs.astral.sh/uv/) fetches ++> the pre-built wheel from the ++> [`v0.11.4-subpath` GitHub Release](https://github.com/SushantGautam/open-webui/releases/tag/v0.11.4-subpath) ++> and runs it, no environment to manage: ++> ++> ```bash ++> WEBUI_SUBPATH=/chat uvx --from https://github.com/SushantGautam/open-webui/releases/download/v0.11.4-subpath/open_webui-0.11.4-py3-none-any.whl open-webui serve ++> ``` ++> ++> Great for a quick run. For a permanent deployment (persistent data, ++> secrets, models), install into an environment instead: ++> ++> - **pip / uv** — install the same pre-built wheel: ++> ++> ```bash ++> pip install https://github.com/SushantGautam/open-webui/releases/download/v0.11.4-subpath/open_webui-0.11.4-py3-none-any.whl ++> # or: uv pip install ++> WEBUI_SUBPATH=/chat open-webui serve ++> ``` ++> ++> The subpath is **baked in at build time** — one image/wheel serves one ++> subpath. Serve behind a proxy that forwards the prefix **without stripping** ++> (e.g. `/chat/* → open-webui:8080`). ++> ++> 📖 **Full details: [FORK.md](FORK.md)** — what the patch changes, tag naming, ++> publishing, proxy setup, and the upgrade recipe. ++ + Open WebUI is **a home for AI**, a self-hosted AI platform that's **[extensible](https://docs.openwebui.com/features/extensibility/plugin/)**, **[feature-rich](https://docs.openwebui.com/features/)**, user-friendly, and built to run **[entirely offline](https://openwebui.com/sovereign-ai)**. With support for **Ollama** and **OpenAI-compatible APIs**, it gives you a powerful, provider-agnostic interface for both local and cloud-based models. + + Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/) diff --git a/backend/open_webui/__init__.py b/backend/open_webui/__init__.py -index e803cea46..ad490bb56 100644 +index 0611bda1c6250e152608795c60b4d6c9a657c2e7..03ef264b74d060c06ab540ce4aa6e3594cb33e08 100644 --- a/backend/open_webui/__init__.py +++ b/backend/open_webui/__init__.py -@@ -74,7 +74,11 @@ def serve( +@@ -76,7 +76,11 @@ def serve( os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH) import open_webui.main # noqa: F401 @@ -69,7 +415,7 @@ index e803cea46..ad490bb56 100644 # On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop, # which is incompatible with psycopg v3 async. Setting loop='none' lets -@@ -89,6 +93,7 @@ def serve( +@@ -91,6 +95,7 @@ def serve( workers=UVICORN_WORKERS, ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE, loop=loop, @@ -77,7 +423,7 @@ index e803cea46..ad490bb56 100644 ) -@@ -98,7 +103,7 @@ def dev( +@@ -100,7 +105,7 @@ def dev( port: int = 8080, reload: bool = True, ): @@ -86,19 +432,19 @@ index e803cea46..ad490bb56 100644 uvicorn.run( 'open_webui.main:app', -@@ -107,6 +112,7 @@ def dev( +@@ -109,6 +114,7 @@ def dev( reload=reload, - forwarded_allow_ips='*', + forwarded_allow_ips=os.getenv('FORWARDED_ALLOW_IPS', '*'), ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE, + root_path=WEBUI_SUBPATH, ) diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py -index fe5f8efb2..542845535 100644 +index 1f3a719bcf6d2ccf074cae939490cb0ca26eb65c..f4387a13033caa911c31cdf9c5f7bc8a1c343cc7 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py -@@ -958,6 +958,30 @@ if WEBUI_NAME != 'Open WebUI': +@@ -964,6 +964,30 @@ if WEBUI_NAME != 'Open WebUI': # https://docs.openwebui.com/license. WEBUI_FAVICON_URL = 'https://openwebui.com/favicon.png' WEBUI_BUILD_HASH = os.getenv('WEBUI_BUILD_HASH', 'dev-build') @@ -130,10 +476,10 @@ index fe5f8efb2..542845535 100644 #################################### diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py -index e7c291513..a307871df 100644 +index f5181fb1b65c84e997e0b672fd2b8add82f0d220..f95a398483c0abb6fd2828d49935daad0e60688d 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py -@@ -125,6 +125,7 @@ from open_webui.env import ( +@@ -127,6 +127,7 @@ from open_webui.env import ( WEBUI_SECRET_KEY, WEBUI_SESSION_COOKIE_SAME_SITE, WEBUI_SESSION_COOKIE_SECURE, @@ -141,17 +487,62 @@ index e7c291513..a307871df 100644 ) from open_webui.events import ( EVENTS, -@@ -509,7 +510,9 @@ app = FastAPI( +@@ -280,6 +281,8 @@ from open_webui.utils.plugin import install_tool_and_function_dependencies + from open_webui.utils.recurrence import RecurrenceEvaluationTimeout + from open_webui.utils.redis import get_redis_client + from open_webui.utils.session_pool import cleanup_response, get_client_timeout, get_session, stream_wrapper ++from open_webui.utils.telemetry.genai.streaming import wrap_stream as wrap_genai_stream ++from open_webui.utils.telemetry.genai.tracer import GenAITracer + from open_webui.utils.tool_approval import ( + ResolveToolCallForm, + build_tool_approval_resume_payload, +@@ -538,7 +541,10 @@ app = FastAPI( openapi_url='/openapi.json' if ENV == 'dev' else None, redoc_url=None, lifespan=lifespan, + root_path=WEBUI_SUBPATH, ) +log.info('FastAPI app initialized with root_path=%r (from WEBUI_SUBPATH).', app.root_path) ++ai_tracer = GenAITracer() @app.exception_handler(RecurrenceEvaluationTimeout) -@@ -2312,6 +2315,7 @@ async def get_app_config(request: Request): +@@ -1635,7 +1641,7 @@ async def chat_completion( + detail=str(e), + ) + +- async def process_chat(request, form_data, user, metadata, model, tasks=None): ++ async def _process_chat(request, form_data, user, metadata, model, tasks=None): + error_detail = None + try: + try: +@@ -1805,6 +1811,25 @@ async def chat_completion( + except Exception: + log.exception('Failed to process pending internal messages for chat %s', metadata.get('chat_id')) + ++ async def process_chat(request, form_data, user, metadata, model, tasks=None): ++ with ai_tracer.workflow( ++ 'invoke_workflow open-webui.chat', ++ operation_name='invoke_workflow', ++ conversation_id=metadata.get('chat_id'), ++ message_id=metadata.get('message_id'), ++ purpose='primary', ++ _defer_end=True, ++ ) as workflow_span: ++ result = await _process_chat(request, form_data, user, metadata, model, tasks) ++ if isinstance(result, StreamingResponse): ++ result.body_iterator = wrap_genai_stream(workflow_span, result.body_iterator) ++ else: ++ try: ++ workflow_span.end() ++ except Exception: ++ log.exception('Unable to end chat workflow span') ++ return result ++ + # Fan out: one task per model + if metadata.get('session_id') and metadata.get('chat_id'): + task_ids = [] +@@ -2375,6 +2400,7 @@ async def get_app_config(request: Request): 'status': True, 'name': app.state.WEBUI_NAME, 'version': VERSION, @@ -159,7 +550,7 @@ index e7c291513..a307871df 100644 'default_locale': str(DEFAULT_LOCALE), 'i18n': config.get('ui.i18n') or {}, 'oauth': { -@@ -2889,7 +2893,7 @@ async def get_manifest_json(): +@@ -2970,7 +2996,7 @@ async def get_manifest_json(): 'name': app.state.WEBUI_NAME, 'short_name': app.state.WEBUI_NAME, 'description': f'{app.state.WEBUI_NAME} is an open, extensible, user-friendly interface for AI that adapts to your workflow.', @@ -168,7 +559,7 @@ index e7c291513..a307871df 100644 'display': 'standalone', 'background_color': '#343541', 'icons': [ -@@ -2897,20 +2901,20 @@ async def get_manifest_json(): +@@ -2978,20 +3004,20 @@ async def get_manifest_json(): # Do not alter, remove, obscure, or replace it except as LICENSE permits: # https://docs.openwebui.com/license. { @@ -192,7 +583,7 @@ index e7c291513..a307871df 100644 'method': 'GET', 'params': {'text': 'shared'}, }, -@@ -3055,9 +3059,9 @@ def swagger_ui_html(*args, **kwargs): +@@ -3136,9 +3162,9 @@ def swagger_ui_html(*args, **kwargs): return get_swagger_ui_html( *args, **kwargs, @@ -205,11 +596,58 @@ index e7c291513..a307871df 100644 ) +diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py +index a0b2d3a11e403d9f0dc0f6ca8ae1c89a6c92d2ec..931b8f893eed1fc36b6a4b0fee21a8144da03202 100644 +--- a/backend/open_webui/retrieval/utils.py ++++ b/backend/open_webui/retrieval/utils.py +@@ -56,8 +56,11 @@ from open_webui.utils.access_control.files import get_owner_accessible_folder_fi + from open_webui.utils.access_control.folders import has_folder_access + from open_webui.utils.headers import get_json_bearer_headers, include_user_info_headers + from open_webui.utils.misc import get_content_from_message, get_message_list ++from open_webui.utils.telemetry.genai.retrieval import instrument_reranker, traced_retrieval ++from open_webui.utils.telemetry.genai.tracer import GenAITracer + + log = logging.getLogger(__name__) ++ai_tracer = GenAITracer() + + + from typing import Any +@@ -1258,8 +1261,11 @@ def get_reranking_function(reranking_engine, reranking_model, reranking_function + if reranking_function is None: + return None + if reranking_engine == 'external': +- return lambda query, documents, user=None: reranking_function.predict( +- [(query, doc.page_content) for doc in documents], user=user ++ return instrument_reranker( ++ tracer=ai_tracer, ++ reranking_function=lambda query, documents, user=None: reranking_function.predict( ++ [(query, doc.page_content) for doc in documents], user=user ++ ), + ) + else: + +@@ -1269,7 +1275,7 @@ def get_reranking_function(reranking_engine, reranking_model, reranking_function + [(query, doc.page_content) for doc in documents], batch_size=int(reranking_batch_size) + ) + +- return predict ++ return instrument_reranker(tracer=ai_tracer, reranking_function=predict) + + + # UUIDs, SHA-256 digests, and prefixed variants thereof all fit [A-Za-z0-9_-]. +@@ -1353,6 +1359,7 @@ def filter_source_metadata(metadata: dict) -> dict: + return {key: metadata[key] for key in RAG_SOURCE_METADATA_KEYS if metadata.get(key) is not None} + + ++@traced_retrieval(ai_tracer) + async def get_sources_from_items( + request, + items, diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py -index e8426b1da..f2f699e0b 100644 +index 6925275d98020d6a225d32567a02511473c9cae1..ff829a530974c4df46479a2592a8d4b54006160e 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py -@@ -784,6 +784,10 @@ async def get_model_profile_image( +@@ -999,6 +999,10 @@ async def get_model_profile_image( bypass_access_control = BYPASS_MODEL_ACCESS_CONTROL or (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) @@ -220,7 +658,7 @@ index e8426b1da..f2f699e0b 100644 # First, check the database for regular models model_meta = await Models.get_model_meta_by_id(id, db=db) if model_meta: -@@ -838,7 +842,7 @@ async def get_model_profile_image( +@@ -1056,7 +1060,7 @@ async def get_model_profile_image( # Do not alter, remove, obscure, or replace it except as LICENSE permits: # https://docs.openwebui.com/license. return RedirectResponse( @@ -229,7 +667,7 @@ index e8426b1da..f2f699e0b 100644 status_code=status.HTTP_302_FOUND, ) -@@ -860,7 +864,7 @@ async def get_model_profile_image( +@@ -1078,7 +1082,7 @@ async def get_model_profile_image( safe_static = _safe_static_redirect_path(profile_image_url) if safe_static: return RedirectResponse( @@ -238,7 +676,7 @@ index e8426b1da..f2f699e0b 100644 status_code=status.HTTP_302_FOUND, ) -@@ -868,7 +872,7 @@ async def get_model_profile_image( +@@ -1086,7 +1090,7 @@ async def get_model_profile_image( # Do not alter, remove, obscure, or replace it except as LICENSE permits: # https://docs.openwebui.com/license. return RedirectResponse( @@ -247,11 +685,145 @@ index e8426b1da..f2f699e0b 100644 status_code=status.HTTP_302_FOUND, ) +diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py +index fd5bf331aff445c95650e556da3a58455ddc6287..5a8a1f05372328ef1ffd7e82b76616d3fa484bf4 100644 +--- a/backend/open_webui/routers/ollama.py ++++ b/backend/open_webui/routers/ollama.py +@@ -44,10 +44,13 @@ from open_webui.utils.payload import ( + apply_system_prompt_to_body, + ) + from open_webui.utils.session_pool import cleanup_response, get_client_timeout, get_session, stream_wrapper ++from open_webui.utils.telemetry.genai.inference import traced_inference ++from open_webui.utils.telemetry.genai.tracer import GenAITracer + from pydantic import BaseModel, ConfigDict, validator + from sqlalchemy.ext.asyncio import AsyncSession + + log = logging.getLogger(__name__) ++ai_tracer = GenAITracer() + + # Headers that become stale after aiohttp auto-decompresses the upstream + # response body. Forwarding them verbatim causes desktop / programmatic +@@ -89,7 +92,7 @@ async def send_get_request( + return None + + +-async def send_request( ++async def _send_request( + url: str, + method: str = 'POST', + *, +@@ -183,6 +186,63 @@ async def send_request( + await cleanup_response(r) + + ++async def send_request( ++ url: str, ++ method: str = 'POST', ++ *, ++ payload: Union[str, bytes | None] = None, ++ key: str | None = None, ++ user: UserModel = None, ++ stream: bool = False, ++ passthrough: bool = False, ++ content_type: str | None = None, ++ metadata: dict | None = None, ++ api_config: dict | None = None, ++ request: Request | None = None, ++): ++ if '/chat/completions' not in url: ++ return await _send_request( ++ url, ++ method, ++ payload=payload, ++ key=key, ++ user=user, ++ stream=stream, ++ passthrough=passthrough, ++ content_type=content_type, ++ metadata=metadata, ++ api_config=api_config, ++ request=request, ++ ) ++ ++ try: ++ request_payload = JSONCodec.loads(payload) if isinstance(payload, (str, bytes)) else (payload or {}) ++ except Exception: ++ request_payload = {} ++ ++ async def call(): ++ return await _send_request( ++ url, ++ method, ++ payload=payload, ++ key=key, ++ user=user, ++ stream=stream, ++ passthrough=passthrough, ++ content_type=content_type, ++ metadata=metadata, ++ api_config=api_config, ++ request=request, ++ ) ++ ++ return await traced_inference( ++ ai_tracer, ++ provider='ollama', ++ request_payload=request_payload, ++ call=call, ++ ) ++ ++ + def get_api_key(idx, url, configs): + parsed_url = urlparse(url) + base_url = f'{parsed_url.scheme}://{parsed_url.netloc}' +diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py +index 8548a8ab0dd6c99c6585fb881f36db4b21f2799b..d867b3a3bccac559ab9993f39bac1a3783f1c290 100644 +--- a/backend/open_webui/routers/openai.py ++++ b/backend/open_webui/routers/openai.py +@@ -54,10 +54,13 @@ from open_webui.utils.session_pool import ( + get_session, + stream_wrapper, + ) ++from open_webui.utils.telemetry.genai.inference import traced_inference ++from open_webui.utils.telemetry.genai.tracer import GenAITracer + from pydantic import BaseModel, ConfigDict + from sqlalchemy.ext.asyncio import AsyncSession + + log = logging.getLogger(__name__) ++ai_tracer = GenAITracer() + + + ########################################## +@@ -1408,6 +1411,24 @@ async def generate_chat_completion( + request: Request, + form_data: dict, + user=Depends(get_verified_user), ++): ++ request_payload = dict(form_data) ++ ++ async def call(): ++ return await _generate_chat_completion(request, form_data, user) ++ ++ return await traced_inference( ++ ai_tracer, ++ provider='openai-compatible', ++ request_payload=request_payload, ++ call=call, ++ ) ++ ++ ++async def _generate_chat_completion( ++ request: Request, ++ form_data: dict, ++ user=Depends(get_verified_user), + ): + if not await Config.get('openai.enable'): + raise HTTPException(status_code=503, detail='OpenAI API is disabled') diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py -index 2e0969ab2..f33cc3cfa 100644 +index c2e7d810d57bcc4440b2c0945f9f4a34e6966d1d..6900836850636ca39f01da23339ca128207a4c78 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py -@@ -31,6 +31,7 @@ from open_webui.env import ( +@@ -34,6 +34,7 @@ from open_webui.env import ( WEBSOCKET_SERVER_LOGGING, WEBSOCKET_SERVER_PING_INTERVAL, WEBSOCKET_SERVER_PING_TIMEOUT, @@ -259,7 +831,7 @@ index 2e0969ab2..f33cc3cfa 100644 ) from open_webui.models.access_grants import AccessGrants from open_webui.models.channels import Channels -@@ -308,9 +309,14 @@ async def periodic_usage_pool_cleanup(): +@@ -321,9 +322,14 @@ async def periodic_usage_pool_cleanup(): await asyncio.sleep(retry_delay) @@ -275,8 +847,1021 @@ index 2e0969ab2..f33cc3cfa 100644 ) +diff --git a/backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_chat.json b/backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_chat.json +new file mode 100644 +index 0000000000000000000000000000000000000000..520090f55ead96053ccc3a1b91ec637118e1df13 +--- /dev/null ++++ b/backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_chat.json +@@ -0,0 +1,5 @@ ++[ ++ {"name": "embedding", "parent": "retrieval", "attributes": ["openinference.span.kind"]}, ++ {"name": "retrieval", "parent": "invoke_workflow open-webui.chat", "attributes": ["gen_ai.operation.name", "gen_ai.retrieval.documents", "openinference.span.kind", "openwebui.retrieval.top_k"]}, ++ {"name": "invoke_workflow open-webui.chat", "parent": null, "attributes": ["gen_ai.operation.name", "openinference.span.kind", "openwebui.operation.purpose"]} ++] +diff --git a/backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_rerank_tool.json b/backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_rerank_tool.json +new file mode 100644 +index 0000000000000000000000000000000000000000..9d5a6884b624a62bafaf2fffbfb6ecf61070d2d6 +--- /dev/null ++++ b/backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_rerank_tool.json +@@ -0,0 +1,8 @@ ++[ ++ {"name": "embedding", "parent": "retrieval", "attributes": ["openinference.span.kind"]}, ++ {"name": "vector_search", "parent": "retrieval", "attributes": []}, ++ {"name": "rerank", "parent": "retrieval", "attributes": ["openinference.span.kind", "openwebui.retrieval.candidate_count"]}, ++ {"name": "execute_tool", "parent": "retrieval", "attributes": ["gen_ai.operation.name", "gen_ai.tool.call.id", "gen_ai.tool.name", "gen_ai.tool.type", "openinference.span.kind"]}, ++ {"name": "retrieval", "parent": "invoke_workflow open-webui.chat", "attributes": ["gen_ai.operation.name", "gen_ai.retrieval.documents", "openinference.span.kind", "openwebui.retrieval.top_k"]}, ++ {"name": "invoke_workflow open-webui.chat", "parent": null, "attributes": ["gen_ai.operation.name", "openinference.span.kind", "openwebui.operation.purpose"]} ++] +diff --git a/backend/open_webui/test/apps/webui/utils/telemetry/golden/simple_chat.json b/backend/open_webui/test/apps/webui/utils/telemetry/golden/simple_chat.json +new file mode 100644 +index 0000000000000000000000000000000000000000..9d0688479293683abd76750dc41f3993c76fab58 +--- /dev/null ++++ b/backend/open_webui/test/apps/webui/utils/telemetry/golden/simple_chat.json +@@ -0,0 +1,4 @@ ++[ ++ {"name": "chat_completion", "parent": "invoke_workflow open-webui.chat", "attributes": ["gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.finish_reasons", "gen_ai.response.id", "gen_ai.response.model", "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "openinference.span.kind", "openwebui.operation.purpose"]}, ++ {"name": "invoke_workflow open-webui.chat", "parent": null, "attributes": ["gen_ai.operation.name", "openinference.span.kind", "openwebui.operation.purpose"]} ++] +diff --git a/backend/open_webui/test/apps/webui/utils/telemetry/golden/tool_loop.json b/backend/open_webui/test/apps/webui/utils/telemetry/golden/tool_loop.json +new file mode 100644 +index 0000000000000000000000000000000000000000..d20d1e45a7e032f58ec4ab655e20e3478e64712f +--- /dev/null ++++ b/backend/open_webui/test/apps/webui/utils/telemetry/golden/tool_loop.json +@@ -0,0 +1,5 @@ ++[ ++ {"name": "chat_completion", "parent": "invoke_workflow open-webui.chat", "attributes": ["gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.finish_reasons", "gen_ai.response.id", "gen_ai.response.model", "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "openinference.span.kind", "openwebui.operation.purpose"]}, ++ {"name": "chat_completion", "parent": "invoke_workflow open-webui.chat", "attributes": ["gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.finish_reasons", "gen_ai.response.id", "gen_ai.response.model", "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "openinference.span.kind", "openwebui.operation.purpose"]}, ++ {"name": "invoke_workflow open-webui.chat", "parent": null, "attributes": ["gen_ai.operation.name", "openinference.span.kind", "openwebui.operation.purpose"]} ++] +diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_conformance.py b/backend/open_webui/test/apps/webui/utils/test_genai_conformance.py +new file mode 100644 +index 0000000000000000000000000000000000000000..8f62c20fa0b59e050d6907b196cc83227e977799 +--- /dev/null ++++ b/backend/open_webui/test/apps/webui/utils/test_genai_conformance.py +@@ -0,0 +1,228 @@ ++import asyncio ++import json ++from pathlib import Path ++ ++import pytest ++from open_webui.utils.telemetry.genai.config import GenAIConfig ++from open_webui.utils.telemetry.genai.inference import traced_inference ++from open_webui.utils.telemetry.genai.retrieval import instrument_embedding, instrument_reranker ++from open_webui.utils.telemetry.genai.tools import traced_tool ++from open_webui.utils.telemetry.genai.tracer import GenAITracer ++from opentelemetry import trace ++from opentelemetry.sdk.trace import TracerProvider ++from opentelemetry.sdk.trace.export import SimpleSpanProcessor ++from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter ++from opentelemetry.trace import StatusCode ++ ++GOLDEN_DIR = Path(__file__).parent / 'telemetry' / 'golden' ++ ++ ++def setup(config=None): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ return GenAITracer(provider.get_tracer('conformance'), config or GenAIConfig()), exporter ++ ++ ++def topology(spans): ++ by_id = {span.context.span_id: span.name for span in spans} ++ return [ ++ { ++ 'name': span.name, ++ 'parent': by_id.get(span.parent.span_id) if span.parent else None, ++ 'attributes': sorted(span.attributes), ++ } ++ for span in spans ++ ] ++ ++ ++async def successful_response(): ++ return { ++ 'id': 'response-id', ++ 'model': 'actual-model', ++ 'usage': {'prompt_tokens': 2, 'completion_tokens': 3}, ++ 'choices': [{'finish_reason': 'stop'}], ++ } ++ ++ ++async def build_scenario(name, tracer): ++ with tracer.workflow('invoke_workflow open-webui.chat', operation_name='invoke_workflow', purpose='primary'): ++ if name in {'simple_chat', 'openai', 'ollama', 'no_chat_id', 'background_title', 'followup'}: ++ task = None ++ if name == 'background_title': ++ task = 'title_generation' ++ elif name == 'followup': ++ task = 'follow_up_generation' ++ await traced_inference( ++ tracer, ++ provider='ollama' if name == 'ollama' else 'openai-compatible', ++ request_payload={'model': 'requested-model', 'metadata': {'task': task} if task else {}}, ++ call=successful_response, ++ ) ++ elif name == 'streaming': ++ ++ async def stream(): ++ yield 'first' ++ yield 'final' ++ ++ async def response(): ++ return type('Response', (), {'body_iterator': stream()})() ++ ++ result = await traced_inference( ++ tracer, ++ provider='openai-compatible', ++ request_payload={'model': 'model', 'stream': True}, ++ call=response, ++ ) ++ [item async for item in result.body_iterator] ++ elif name == 'cancelled': ++ span = tracer.tracer.start_span('chat_completion') ++ ++ async def stream(): ++ yield 'first' ++ await asyncio.sleep(10) ++ ++ from open_webui.utils.telemetry.genai.streaming import wrap_stream ++ ++ wrapped = wrap_stream(span, stream()) ++ await wrapped.__anext__() ++ await wrapped.aclose() ++ elif name in {'one_tool', 'multiple_tools', 'mcp_tool'}: ++ with tracer.inference('chat_completion'): ++ count = 1 if name != 'multiple_tools' else 2 ++ for index in range(count): ++ ++ async def call(index=index): ++ return {'result': index} ++ ++ await traced_tool( ++ tracer, ++ name='mcp' if name == 'mcp_tool' else f'tool-{index}', ++ call_id=f'call-{index}', ++ tool_type='mcp' if name == 'mcp_tool' else 'function', ++ arguments={'index': index}, ++ direct=False, ++ call=call, ++ ) ++ if name == 'one_tool': ++ await traced_inference( ++ tracer, ++ provider='openai-compatible', ++ request_payload={'model': 'model'}, ++ call=successful_response, ++ ) ++ elif name == 'multi_round': ++ await traced_inference( ++ tracer, provider='openai-compatible', request_payload={'model': 'model'}, call=successful_response ++ ) ++ await traced_inference( ++ tracer, provider='openai-compatible', request_payload={'model': 'model'}, call=successful_response ++ ) ++ elif name in {'knowledge', 'hybrid', 'rerank', 'embedding', 'retrieval_failure'}: ++ with tracer.retrieval('retrieval', operation_name='retrieval', retrieval_query='query', top_k=3): ++ ++ async def embedding(query, prefix=None): ++ return [[0.1, 0.2]] ++ ++ await instrument_embedding(tracer, embedding)('query') ++ if name in {'hybrid', 'rerank'}: ++ with tracer.tracer.start_as_current_span('bm25_search' if name == 'hybrid' else 'vector_search'): ++ pass ++ if name == 'rerank': ++ instrument_reranker(tracer, lambda query, docs: [0.9])('query', ['doc']) ++ await traced_tool( ++ tracer, ++ name='retrieval-tool', ++ call_id='retrieval-call', ++ tool_type='function', ++ arguments={'query': 'query'}, ++ direct=False, ++ call=lambda: successful_response(), ++ ) ++ if name == 'retrieval_failure': ++ with pytest.raises(RuntimeError): ++ raise RuntimeError('retrieval failed') ++ if name in {'knowledge', 'hybrid', 'rerank'}: ++ current = trace.get_current_span() ++ current.set_attribute( ++ 'gen_ai.retrieval.documents', ['{"document_id":"doc-1","rank":1,"score":0.9}'] ++ ) ++ elif name == 'llm_failure': ++ ++ async def fail(): ++ raise ValueError('llm failed') ++ ++ with pytest.raises(ValueError): ++ await traced_inference( ++ tracer, provider='openai-compatible', request_payload={'model': 'model'}, call=fail ++ ) ++ elif name == 'no_content': ++ await traced_inference( ++ tracer, ++ provider='openai-compatible', ++ request_payload={'model': 'model', 'messages': [{'content': 'secret'}]}, ++ call=successful_response, ++ ) ++ ++ ++@pytest.mark.asyncio ++@pytest.mark.parametrize( ++ 'scenario', ++ [ ++ 'simple_chat', ++ 'streaming', ++ 'cancelled', ++ 'openai', ++ 'ollama', ++ 'one_tool', ++ 'multiple_tools', ++ 'multi_round', ++ 'knowledge', ++ 'hybrid', ++ 'rerank', ++ 'embedding', ++ 'mcp_tool', ++ 'background_title', ++ 'followup', ++ 'no_chat_id', ++ 'llm_failure', ++ 'retrieval_failure', ++ 'no_content', ++ ], ++) ++async def test_conformance_scenarios(scenario): ++ tracer, exporter = setup() ++ await build_scenario(scenario, tracer) ++ spans = exporter.get_finished_spans() ++ assert spans ++ if scenario == 'llm_failure': ++ assert any(span.status.status_code is StatusCode.ERROR for span in spans) ++ if scenario == 'no_content': ++ assert all('gen_ai.input.messages' not in span.attributes for span in spans) ++ if scenario == 'background_title': ++ assert any(span.attributes.get('openwebui.operation.purpose') == 'title_generation' for span in spans) ++ if scenario == 'followup': ++ assert any(span.attributes.get('openwebui.operation.purpose') == 'followup_generation' for span in spans) ++ ++ ++@pytest.mark.asyncio ++@pytest.mark.parametrize('scenario', ['simple_chat', 'knowledge', 'multi_round', 'rerank']) ++async def test_golden_topology(scenario): ++ tracer, exporter = setup() ++ await build_scenario(scenario, tracer) ++ actual = topology(exporter.get_finished_spans()) ++ golden_name = { ++ 'knowledge': 'rag_chat', ++ 'multi_round': 'tool_loop', ++ 'rerank': 'rag_rerank_tool', ++ }.get(scenario, scenario) ++ expected = json.loads((GOLDEN_DIR / f'{golden_name}.json').read_text()) ++ assert actual == expected ++ ++ ++def test_tracing_suppressed_produces_no_semantic_spans(): ++ exporter = InMemorySpanExporter() ++ tracer = GenAITracer(trace.get_tracer('suppressed'), GenAIConfig()) ++ with tracer.workflow('invoke_workflow open-webui.chat'): ++ pass ++ assert exporter.get_finished_spans() == () +diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_inference.py b/backend/open_webui/test/apps/webui/utils/test_genai_inference.py +new file mode 100644 +index 0000000000000000000000000000000000000000..52d3f16d9d5b3da0871f1c80c1fe3370c5c680ef +--- /dev/null ++++ b/backend/open_webui/test/apps/webui/utils/test_genai_inference.py +@@ -0,0 +1,134 @@ ++from types import SimpleNamespace ++ ++import pytest ++from open_webui.utils.telemetry.genai.config import GenAIConfig ++from open_webui.utils.telemetry.genai.inference import traced_inference ++from open_webui.utils.telemetry.genai.tracer import GenAITracer ++from opentelemetry.sdk.trace import TracerProvider ++from opentelemetry.sdk.trace.export import SimpleSpanProcessor ++from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter ++ ++ ++@pytest.mark.asyncio ++async def test_two_round_tool_loop_creates_independent_inference_spans(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ tracer = GenAITracer(provider.get_tracer('inference-test'), GenAIConfig()) ++ responses = [ ++ { ++ 'id': 'one', ++ 'model': 'requested', ++ 'usage': {'prompt_tokens': 3, 'completion_tokens': 4}, ++ 'choices': [{'finish_reason': 'tool_calls'}], ++ }, ++ { ++ 'id': 'two', ++ 'model': 'actual', ++ 'usage': {'prompt_tokens': 5, 'completion_tokens': 6}, ++ 'choices': [{'finish_reason': 'stop'}], ++ }, ++ ] ++ ++ async def call(): ++ return responses.pop(0) ++ ++ await traced_inference(tracer, provider='openai-compatible', request_payload={'model': 'requested'}, call=call) ++ await traced_inference(tracer, provider='ollama', request_payload={'model': 'requested'}, call=call) ++ spans = exporter.get_finished_spans() ++ assert len(spans) == 2 ++ assert [span.attributes['gen_ai.usage.input_tokens'] for span in spans] == [3, 5] ++ assert [span.attributes['gen_ai.usage.output_tokens'] for span in spans] == [4, 6] ++ assert [span.attributes['gen_ai.response.id'] for span in spans] == ['one', 'two'] ++ ++ ++@pytest.mark.asyncio ++async def test_content_is_absent_for_openai_and_ollama_when_disabled(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ tracer = GenAITracer(provider.get_tracer('privacy-test'), GenAIConfig()) ++ ++ async def call(): ++ return {'id': 'response', 'model': 'model', 'choices': [{'finish_reason': 'stop'}]} ++ ++ payload = {'model': 'model', 'messages': [{'role': 'user', 'content': 'secret'}]} ++ await traced_inference(tracer, provider='openai-compatible', request_payload=payload, call=call) ++ await traced_inference(tracer, provider='ollama', request_payload=payload, call=call) ++ for span in exporter.get_finished_spans(): ++ assert 'gen_ai.input.messages' not in span.attributes ++ assert 'gen_ai.output.messages' not in span.attributes ++ ++ ++@pytest.mark.asyncio ++async def test_buffered_output_is_captured_when_enabled(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ tracer = GenAITracer( ++ provider.get_tracer('output-content-test'), ++ GenAIConfig(capture_outputs=True, content_max_length=12), ++ ) ++ ++ async def call(): ++ return { ++ 'id': 'response', ++ 'model': 'model', ++ 'choices': [{'message': {'role': 'assistant', 'content': 'output is longer'}}], ++ } ++ ++ await traced_inference(tracer, provider='openai-compatible', request_payload={'model': 'model'}, call=call) ++ output = exporter.get_finished_spans()[0].attributes['gen_ai.output.messages'] ++ assert len(output) == 12 ++ assert output.startswith('[{"role": "a') ++ ++ ++@pytest.mark.asyncio ++async def test_streaming_inference_span_closes_after_body_iterator(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ tracer = GenAITracer(provider.get_tracer('stream-test'), GenAIConfig()) ++ ++ async def body(): ++ yield 'chunk' ++ ++ response = SimpleNamespace(body_iterator=body()) ++ result = await traced_inference( ++ tracer, ++ provider='ollama', ++ request_payload={'model': 'model', 'stream': True}, ++ call=lambda: _return(response), ++ ) ++ assert exporter.get_finished_spans() == () ++ assert [item async for item in result.body_iterator] == ['chunk'] ++ assert len(exporter.get_finished_spans()) == 1 ++ ++ ++@pytest.mark.asyncio ++async def test_streaming_output_is_captured_after_body_iteration(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ tracer = GenAITracer( ++ provider.get_tracer('stream-output-test'), ++ GenAIConfig(capture_outputs=True), ++ ) ++ ++ async def body(): ++ yield 'chunk-one' ++ yield 'chunk-two' ++ ++ response = SimpleNamespace(body_iterator=body()) ++ result = await traced_inference( ++ tracer, ++ provider='ollama', ++ request_payload={'model': 'model', 'stream': True}, ++ call=lambda: _return(response), ++ ) ++ assert [item async for item in result.body_iterator] == ['chunk-one', 'chunk-two'] ++ assert exporter.get_finished_spans()[0].attributes['gen_ai.output.messages'] == 'chunk-onechunk-two' ++ ++ ++async def _return(value): ++ return value +diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_performance.py b/backend/open_webui/test/apps/webui/utils/test_genai_performance.py +new file mode 100644 +index 0000000000000000000000000000000000000000..2a6887c671f0e6f748f4e9689b7603a6d0e17ba0 +--- /dev/null ++++ b/backend/open_webui/test/apps/webui/utils/test_genai_performance.py +@@ -0,0 +1,101 @@ ++import timeit ++ ++import pytest ++from open_webui.utils.telemetry.genai import extractors ++from open_webui.utils.telemetry.genai.config import GenAIConfig ++from open_webui.utils.telemetry.genai.retrieval import traced_retrieval ++from open_webui.utils.telemetry.genai.tools import traced_tool ++from open_webui.utils.telemetry.genai.tracer import GenAITracer ++from opentelemetry.sdk.trace import TracerProvider ++from opentelemetry.sdk.trace.export import SimpleSpanProcessor ++from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter ++ ++ ++def _tracer(config): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ return GenAITracer(provider.get_tracer('performance-test'), config), exporter ++ ++ ++def test_disabled_tracing_stays_within_agreed_overhead(): ++ tracer, _ = _tracer(GenAIConfig(enabled=False)) ++ ++ def baseline(): ++ return None ++ ++ def disabled_trace(): ++ with tracer.inference(): ++ pass ++ ++ baseline_time = min(timeit.repeat(baseline, number=20_000, repeat=5)) ++ disabled_time = min(timeit.repeat(disabled_trace, number=20_000, repeat=5)) ++ # Agreed tolerance: at most 20 ms total for 20,000 disabled spans. ++ assert disabled_time - baseline_time <= 0.02 ++ ++ ++def test_disabled_capture_does_not_serialize(monkeypatch): ++ def unexpected_serialization(*args, **kwargs): ++ raise AssertionError('content was serialized while capture was disabled') ++ ++ monkeypatch.setattr(extractors.json, 'dumps', unexpected_serialization) ++ tracer, _ = _tracer(GenAIConfig()) ++ with tracer.inference(inputs={'secret': 'prompt'}, tool_arguments={'secret': 'args'}): ++ pass ++ ++ disabled_tracer, _ = _tracer(GenAIConfig(enabled=False, capture_inputs=True)) ++ with disabled_tracer.inference(inputs={'secret': 'prompt'}): ++ pass ++ ++ ++@pytest.mark.asyncio ++async def test_explicit_content_and_topology_caps(): ++ config = GenAIConfig( ++ capture_inputs=True, ++ capture_outputs=True, ++ capture_tool_arguments=True, ++ capture_tool_results=True, ++ capture_retrieval_documents=True, ++ content_max_length=5, ++ retrieval_max_documents=2, ++ exception_max_length=7, ++ ) ++ tracer, exporter = _tracer(config) ++ ++ async def tool_call(): ++ return 'result-too-long' ++ ++ await traced_tool( ++ tracer, ++ name='limited-tool', ++ call_id='call-1', ++ tool_type='function', ++ arguments='arguments-too-long', ++ direct=False, ++ call=tool_call, ++ ) ++ ++ with pytest.raises(ValueError): ++ with tracer.inference(inputs='message-too-long'): ++ raise ValueError('exception-message-too-long') ++ ++ @traced_retrieval(tracer) ++ async def retrieve(items, queries, k, hybrid_search=False, embedding_function=None): ++ return items ++ ++ items = [ ++ {'metadata': {'document_id': f'doc-{index}'}, 'document': [f'content-{index}'], 'distances': [0.1]} ++ for index in range(4) ++ ] ++ await retrieve(items=items, queries=['query'], k=4) ++ ++ spans = exporter.get_finished_spans() ++ tool_span = next(span for span in spans if span.name == 'execute_tool') ++ assert tool_span.attributes['gen_ai.tool.call.arguments'] == 'argum' ++ assert tool_span.attributes['gen_ai.tool.call.result'] == 'resul' ++ inference_span = next(span for span in spans if span.name == 'inference') ++ assert inference_span.attributes['gen_ai.input.messages'] == 'messa' ++ exception_event = next(event for event in inference_span.events if event.name == 'exception') ++ assert exception_event.attributes['exception.message'] == 'excepti' ++ retrieval_span = next(span for span in spans if span.name == 'retrieval') ++ assert len(retrieval_span.attributes['gen_ai.retrieval.documents']) == 2 +diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_purpose.py b/backend/open_webui/test/apps/webui/utils/test_genai_purpose.py +new file mode 100644 +index 0000000000000000000000000000000000000000..d9dddf95d3355377da8f131224b85110f73f514d +--- /dev/null ++++ b/backend/open_webui/test/apps/webui/utils/test_genai_purpose.py +@@ -0,0 +1,39 @@ ++import pytest ++from open_webui.utils.telemetry.genai.config import GenAIConfig ++from open_webui.utils.telemetry.genai.inference import traced_inference ++from open_webui.utils.telemetry.genai.tracer import GenAITracer ++from opentelemetry.sdk.trace import TracerProvider ++from opentelemetry.sdk.trace.export import SimpleSpanProcessor ++from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter ++ ++ ++@pytest.mark.asyncio ++async def test_task_metadata_maps_background_purpose_and_primary_defaults(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ tracer = GenAITracer(provider.get_tracer('purpose-test'), GenAIConfig()) ++ ++ async def call(): ++ return {'id': 'response', 'model': 'model', 'choices': [{'finish_reason': 'stop'}]} ++ ++ await traced_inference( ++ tracer, ++ provider='openai-compatible', ++ request_payload={'model': 'model', 'metadata': {'task': 'title_generation'}}, ++ call=call, ++ ) ++ await traced_inference(tracer, provider='openai-compatible', request_payload={'model': 'model'}, call=call) ++ spans = exporter.get_finished_spans() ++ assert spans[0].attributes['openwebui.operation.purpose'] == 'title_generation' ++ assert spans[1].attributes['openwebui.operation.purpose'] == 'primary' ++ ++ ++def test_primary_workflow_purpose_is_explicit(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ tracer = GenAITracer(provider.get_tracer('workflow-purpose-test'), GenAIConfig()) ++ with tracer.workflow('invoke_workflow open-webui.chat', operation_name='invoke_workflow', purpose='primary'): ++ pass ++ assert exporter.get_finished_spans()[0].attributes['openwebui.operation.purpose'] == 'primary' +diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py b/backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py +new file mode 100644 +index 0000000000000000000000000000000000000000..24a224718549aa88e795a5de41685c35c8d14db8 +--- /dev/null ++++ b/backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py +@@ -0,0 +1,80 @@ ++import pytest ++from open_webui.utils.telemetry.genai.config import GenAIConfig ++from open_webui.utils.telemetry.genai.retrieval import instrument_embedding, instrument_reranker, traced_retrieval ++from open_webui.utils.telemetry.genai.tracer import GenAITracer ++from opentelemetry.sdk.trace import TracerProvider ++from opentelemetry.sdk.trace.export import SimpleSpanProcessor ++from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter ++ ++ ++@pytest.mark.asyncio ++async def test_retrieval_tree_has_embedding_and_rerank_children(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ tracer = GenAITracer(provider.get_tracer('retrieval-test'), GenAIConfig()) ++ ++ async def embedding(query, prefix=None): ++ return [[0.1, 0.2]] ++ ++ def rerank(query, documents): ++ return [0.9 for _ in documents] ++ ++ with tracer.retrieval('retrieval') as parent: ++ await instrument_embedding(tracer, embedding)('query') ++ instrument_reranker(tracer, rerank)('query', ['document']) ++ spans = exporter.get_finished_spans() ++ assert [span.name for span in spans] == ['embedding', 'rerank', 'retrieval'] ++ assert all(span.parent.span_id == parent.get_span_context().span_id for span in spans[:2]) ++ assert spans[1].attributes['openinference.span.kind'] == 'RERANKER' ++ ++ ++@pytest.mark.asyncio ++async def test_embedding_and_retrieval_content_are_private_by_default(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ tracer = GenAITracer(provider.get_tracer('retrieval-privacy-test'), GenAIConfig()) ++ ++ async def embedding(query, prefix=None): ++ return [[0.1, 0.2]] ++ ++ with tracer.retrieval('retrieval') as parent: ++ await instrument_embedding(tracer, embedding)('secret query') ++ parent.set_attribute('gen_ai.retrieval.documents', ['{"document_id": "doc-1", "rank": 1, "score": 0.9}']) ++ spans = exporter.get_finished_spans() ++ assert 'gen_ai.retrieval.query.text' not in spans[0].attributes ++ assert 'gen_ai.embeddings' not in spans[0].attributes ++ assert spans[-1].attributes['gen_ai.retrieval.documents'] ++ ++ ++@pytest.mark.asyncio ++async def test_embedding_vectors_and_document_content_are_capped_when_enabled(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ tracer = GenAITracer( ++ provider.get_tracer('retrieval-content-test'), ++ GenAIConfig(capture_embedding_vectors=True, capture_retrieval_documents=True, content_max_length=8), ++ ) ++ ++ async def embedding(query, prefix=None): ++ return [[0.1, 0.2, 0.3, 0.4]] ++ ++ @traced_retrieval(tracer) ++ async def retrieve(items, queries, k, hybrid_search=False, embedding_function=None): ++ await instrument_embedding(tracer, embedding_function)('query') ++ return items ++ ++ await retrieve( ++ items=[{'metadata': {'document_id': 'doc-1'}, 'document': ['document-content-too-long']}], ++ queries=['query'], ++ k=1, ++ embedding_function=embedding, ++ ) ++ spans = exporter.get_finished_spans() ++ embedding_span = next(span for span in spans if span.name == 'embedding') ++ retrieval_span = next(span for span in spans if span.name == 'retrieval') ++ assert len(embedding_span.attributes['gen_ai.embeddings']) <= 8 ++ assert '"content": "document"' in retrieval_span.attributes['gen_ai.retrieval.documents'][0] ++ assert 'document-content-too-long' not in retrieval_span.attributes['gen_ai.retrieval.documents'][0] +diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_tools.py b/backend/open_webui/test/apps/webui/utils/test_genai_tools.py +new file mode 100644 +index 0000000000000000000000000000000000000000..d51de00b40817d1f6e859c21da379e2725e27869 +--- /dev/null ++++ b/backend/open_webui/test/apps/webui/utils/test_genai_tools.py +@@ -0,0 +1,100 @@ ++import pytest ++from open_webui.utils.telemetry.genai.config import GenAIConfig ++from open_webui.utils.telemetry.genai.tools import traced_tool ++from open_webui.utils.telemetry.genai.tracer import GenAITracer ++from opentelemetry.sdk.trace import TracerProvider ++from opentelemetry.sdk.trace.export import SimpleSpanProcessor ++from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter ++ ++ ++@pytest.mark.asyncio ++async def test_two_tools_are_independent_children_of_inference(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ tracer = GenAITracer(provider.get_tracer('tools-test'), GenAIConfig()) ++ ++ async def first(): ++ return 'first-result' ++ ++ async def second(): ++ return 'second-result' ++ ++ with tracer.inference('chat_completion') as parent: ++ await traced_tool( ++ tracer, ++ name='first', ++ call_id='call-1', ++ tool_type='function', ++ arguments={'x': 1}, ++ direct=False, ++ call=first, ++ ) ++ await traced_tool( ++ tracer, ++ name='second', ++ call_id='call-2', ++ tool_type='mcp', ++ arguments={'x': 2}, ++ direct=False, ++ call=second, ++ ) ++ ++ spans = exporter.get_finished_spans() ++ assert [span.name for span in spans] == ['execute_tool', 'execute_tool', 'chat_completion'] ++ tool_spans = spans[:2] ++ assert [span.attributes['gen_ai.tool.name'] for span in tool_spans] == ['first', 'second'] ++ assert [span.attributes['gen_ai.tool.call.id'] for span in tool_spans] == ['call-1', 'call-2'] ++ assert [span.attributes['gen_ai.tool.type'] for span in tool_spans] == ['function', 'mcp'] ++ assert all(span.parent.span_id == parent.get_span_context().span_id for span in tool_spans) ++ ++ ++@pytest.mark.asyncio ++async def test_tool_payloads_are_absent_when_capture_is_disabled(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ tracer = GenAITracer(provider.get_tracer('tools-privacy-test'), GenAIConfig()) ++ ++ async def call(): ++ return {'secret': 'result'} ++ ++ await traced_tool( ++ tracer, ++ name='private', ++ call_id='call-private', ++ tool_type='builtin', ++ arguments={'secret': 'argument'}, ++ direct=False, ++ call=call, ++ ) ++ attributes = exporter.get_finished_spans()[0].attributes ++ assert 'gen_ai.tool.call.arguments' not in attributes ++ assert 'gen_ai.tool.call.result' not in attributes ++ ++ ++@pytest.mark.asyncio ++async def test_tool_payloads_are_captured_when_enabled(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ tracer = GenAITracer( ++ provider.get_tracer('tools-content-test'), ++ GenAIConfig(capture_tool_arguments=True, capture_tool_results=True), ++ ) ++ ++ async def call(): ++ return {'result': 'ok'} ++ ++ await traced_tool( ++ tracer, ++ name='captured', ++ call_id='call-captured', ++ tool_type='openapi', ++ arguments={'argument': 'value'}, ++ direct=False, ++ call=call, ++ ) ++ attributes = exporter.get_finished_spans()[0].attributes ++ assert 'value' in attributes['gen_ai.tool.call.arguments'] ++ assert 'ok' in attributes['gen_ai.tool.call.result'] +diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_tracer.py b/backend/open_webui/test/apps/webui/utils/test_genai_tracer.py +new file mode 100644 +index 0000000000000000000000000000000000000000..3eb6e126ee87a7af4d0cd6a450342795be6e4a3e +--- /dev/null ++++ b/backend/open_webui/test/apps/webui/utils/test_genai_tracer.py +@@ -0,0 +1,74 @@ ++import asyncio ++ ++import pytest ++from open_webui.utils.telemetry.genai.config import GenAIConfig ++from open_webui.utils.telemetry.genai.streaming import wrap_stream ++from open_webui.utils.telemetry.genai.tracer import GenAITracer ++from opentelemetry.sdk.trace import TracerProvider ++from opentelemetry.sdk.trace.export import SimpleSpanProcessor ++from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter ++from opentelemetry.trace import StatusCode ++ ++ ++@pytest.fixture ++def span_setup(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ return GenAITracer(provider.get_tracer('test'), GenAIConfig()), exporter ++ ++ ++def test_span_kind_and_structural_attributes(span_setup): ++ tracer, exporter = span_setup ++ with tracer.inference(operation_name='chat', provider='test', request_model='model'): ++ pass ++ span = exporter.get_finished_spans()[0] ++ assert span.attributes['gen_ai.operation.name'] == 'chat' ++ assert span.attributes['gen_ai.provider.name'] == 'test' ++ assert span.attributes['openinference.span.kind'] == 'LLM' ++ ++ ++def test_exception_is_recorded_and_status_is_error(span_setup): ++ tracer, exporter = span_setup ++ with pytest.raises(ValueError): ++ with tracer.inference(): ++ raise ValueError('boom') ++ span = exporter.get_finished_spans()[0] ++ assert span.status.status_code is StatusCode.ERROR ++ assert any(event.name == 'exception' for event in span.events) ++ ++ ++def test_content_is_absent_when_capture_is_disabled(span_setup): ++ tracer, exporter = span_setup ++ with tracer.inference(inputs='secret prompt', outputs='secret output', tool_arguments='secret args'): ++ pass ++ assert not any( ++ 'input' in key or 'output' in key or 'tool' in key for key in exporter.get_finished_spans()[0].attributes ++ ) ++ ++ ++def test_content_is_truncated_when_capture_is_enabled(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ config = GenAIConfig(capture_inputs=True, capture_outputs=True, content_max_length=5) ++ with GenAITracer(provider.get_tracer('test'), config).inference(inputs='123456789', outputs='abcdefgh'): ++ pass ++ span = exporter.get_finished_spans()[0] ++ assert span.attributes['gen_ai.input.messages'] == '12345' ++ assert span.attributes['gen_ai.output.messages'] == 'abcde' ++ ++ ++@pytest.mark.asyncio ++async def test_streaming_cancellation_closes_span(span_setup): ++ tracer, exporter = span_setup ++ span = tracer.tracer.start_span('stream') ++ ++ async def source(): ++ yield 'first' ++ await asyncio.sleep(10) ++ ++ wrapped = wrap_stream(span, source()) ++ assert await wrapped.__anext__() == 'first' ++ await wrapped.aclose() ++ assert len(exporter.get_finished_spans()) == 1 +diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_workflow_context.py b/backend/open_webui/test/apps/webui/utils/test_genai_workflow_context.py +new file mode 100644 +index 0000000000000000000000000000000000000000..166353887f05901e61fc8153ca466b1dec3ff372 +--- /dev/null ++++ b/backend/open_webui/test/apps/webui/utils/test_genai_workflow_context.py +@@ -0,0 +1,68 @@ ++import asyncio ++ ++import pytest ++from open_webui.utils.telemetry.genai.streaming import wrap_stream ++from open_webui.utils.telemetry.genai.tracer import GenAITracer ++from opentelemetry.sdk.trace import TracerProvider ++from opentelemetry.sdk.trace.export import SimpleSpanProcessor ++from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter ++ ++ ++@pytest.fixture ++def tracer_setup(): ++ exporter = InMemorySpanExporter() ++ provider = TracerProvider() ++ provider.add_span_processor(SimpleSpanProcessor(exporter)) ++ return GenAITracer(provider.get_tracer('workflow-test')), exporter ++ ++ ++@pytest.mark.asyncio ++async def test_workflow_name_context_and_stream_lifetime(tracer_setup): ++ tracer, exporter = tracer_setup ++ span = tracer.tracer.start_span('invoke_workflow open-webui.chat') ++ stream_started = asyncio.Event() ++ ++ async def source(): ++ stream_started.set() ++ yield 'chunk' ++ await asyncio.sleep(0) ++ yield 'done' ++ ++ wrapped = wrap_stream(span, source()) ++ assert await wrapped.__anext__() == 'chunk' ++ await stream_started.wait() ++ assert exporter.get_finished_spans() == () ++ assert await wrapped.__anext__() == 'done' ++ with pytest.raises(StopAsyncIteration): ++ await wrapped.__anext__() ++ assert exporter.get_finished_spans()[0].name == 'invoke_workflow open-webui.chat' ++ ++ ++def test_workflow_context_attributes_are_ids_only(tracer_setup): ++ tracer, exporter = tracer_setup ++ with tracer.workflow( ++ 'invoke_workflow open-webui.chat', ++ operation_name='invoke_workflow', ++ conversation_id='chat-123', ++ message_id='message-456', ++ purpose='primary', ++ ): ++ pass ++ span = exporter.get_finished_spans()[0] ++ assert span.name == 'invoke_workflow open-webui.chat' ++ assert span.attributes['gen_ai.conversation.id'] == 'chat-123' ++ assert span.attributes['openwebui.message.id'] == 'message-456' ++ assert span.attributes['openwebui.operation.purpose'] == 'primary' ++ assert 'chat-123' not in span.name ++ ++ ++def test_background_workflow_can_set_non_primary_purpose(tracer_setup): ++ tracer, exporter = tracer_setup ++ with tracer.workflow( ++ 'invoke_workflow open-webui.chat', ++ operation_name='invoke_workflow', ++ conversation_id='chat-123', ++ purpose='title_generation', ++ ): ++ pass ++ assert exporter.get_finished_spans()[0].attributes['openwebui.operation.purpose'] == 'title_generation' +diff --git a/backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py b/backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py +new file mode 100644 +index 0000000000000000000000000000000000000000..b409e92831d68ff1b4e3615b1f7d5b04ce2e218b +--- /dev/null ++++ b/backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py +@@ -0,0 +1,89 @@ ++import importlib ++ ++ ++class _Instrumentor: ++ def instrument(self): ++ pass ++ ++ ++def _reload_exporter_modules(monkeypatch, exporter_headers): ++ monkeypatch.setenv('OTEL_EXPORTER_OTLP_HEADERS', 'foo=bar') ++ monkeypatch.delenv('OTEL_BASIC_AUTH_USERNAME', raising=False) ++ monkeypatch.delenv('OTEL_BASIC_AUTH_PASSWORD', raising=False) ++ ++ import opentelemetry.exporter.otlp.proto.grpc.trace_exporter as grpc_exporter ++ import opentelemetry.exporter.otlp.proto.http.trace_exporter as http_exporter ++ ++ importlib.reload(grpc_exporter) ++ importlib.reload(http_exporter) ++ ++ grpc = grpc_exporter.OTLPSpanExporter(endpoint='http://localhost:4317') ++ http = http_exporter.OTLPSpanExporter(endpoint='http://localhost:4318/v1/traces') ++ exporter_headers.append((grpc._headers, http._headers)) ++ ++ ++def test_sdk_otlp_headers_are_used_when_basic_auth_is_unset(monkeypatch): ++ headers = [] ++ _reload_exporter_modules(monkeypatch, headers) ++ ++ grpc_headers, http_headers = headers[0] ++ assert ('foo', 'bar') in grpc_headers ++ assert http_headers.get('foo') == 'bar' ++ ++ ++def test_setup_passes_none_headers_without_basic_auth(monkeypatch): ++ import open_webui.utils.telemetry.setup as telemetry_setup ++ ++ captured = [] ++ ++ class Exporter: ++ def __init__(self, **kwargs): ++ captured.append(kwargs) ++ ++ class Provider: ++ def add_span_processor(self, processor): ++ pass ++ ++ monkeypatch.setattr(telemetry_setup, 'HttpOTLPSpanExporter', Exporter) ++ monkeypatch.setattr(telemetry_setup, 'OTLPSpanExporter', Exporter) ++ monkeypatch.setattr(telemetry_setup.trace, 'set_tracer_provider', lambda provider: None) ++ monkeypatch.setattr(telemetry_setup.trace, 'get_tracer_provider', lambda: Provider()) ++ monkeypatch.setattr(telemetry_setup, 'Instrumentor', lambda **kwargs: _Instrumentor()) ++ monkeypatch.setattr(telemetry_setup, 'ENABLE_OTEL_TRACES', True) ++ monkeypatch.setattr(telemetry_setup, 'OTEL_BASIC_AUTH_USERNAME', '') ++ monkeypatch.setattr(telemetry_setup, 'OTEL_BASIC_AUTH_PASSWORD', '') ++ monkeypatch.setattr(telemetry_setup, 'OTEL_OTLP_SPAN_EXPORTER', 'grpc') ++ telemetry_setup.setup(None, None) ++ assert captured[-1]['headers'] is None ++ ++ monkeypatch.setattr(telemetry_setup, 'OTEL_OTLP_SPAN_EXPORTER', 'http') ++ telemetry_setup.setup(None, None) ++ assert captured[-1]['headers'] is None ++ ++ ++def test_setup_preserves_basic_auth_and_grpc_insecure(monkeypatch): ++ import open_webui.utils.telemetry.setup as telemetry_setup ++ ++ captured = [] ++ ++ class Exporter: ++ def __init__(self, **kwargs): ++ captured.append(kwargs) ++ ++ class Provider: ++ def add_span_processor(self, processor): ++ pass ++ ++ monkeypatch.setattr(telemetry_setup, 'OTLPSpanExporter', Exporter) ++ monkeypatch.setattr(telemetry_setup.trace, 'set_tracer_provider', lambda provider: None) ++ monkeypatch.setattr(telemetry_setup.trace, 'get_tracer_provider', lambda: Provider()) ++ monkeypatch.setattr(telemetry_setup, 'Instrumentor', lambda **kwargs: _Instrumentor()) ++ monkeypatch.setattr(telemetry_setup, 'ENABLE_OTEL_TRACES', True) ++ monkeypatch.setattr(telemetry_setup, 'OTEL_BASIC_AUTH_USERNAME', 'user') ++ monkeypatch.setattr(telemetry_setup, 'OTEL_BASIC_AUTH_PASSWORD', 'pass') ++ monkeypatch.setattr(telemetry_setup, 'OTEL_OTLP_SPAN_EXPORTER', 'grpc') ++ monkeypatch.setattr(telemetry_setup, 'OTEL_EXPORTER_OTLP_INSECURE', True) ++ telemetry_setup.setup(None, None) ++ ++ assert captured[-1]['headers'] == [('authorization', 'Basic dXNlcjpwYXNz')] ++ assert captured[-1]['insecure'] is True diff --git a/backend/open_webui/utils/asgi_middleware.py b/backend/open_webui/utils/asgi_middleware.py -index 2d9f4e190..7fcdce387 100644 +index 2d9f4e190edd7c9abdaf455fb9143e5a6c9b28a9..7fcdce387437ab0ba221e523c27c690e5f8cb1c2 100644 --- a/backend/open_webui/utils/asgi_middleware.py +++ b/backend/open_webui/utils/asgi_middleware.py @@ -198,7 +198,8 @@ class AppHTTPMiddleware: @@ -289,9 +1874,877 @@ index 2d9f4e190..7fcdce387 100644 response = RedirectResponse(url=redirect_url) await response(scope, receive, send) return True +diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py +index 6eca5112521e8dfb6b78c62264df6a020eab3f68..48e078547112b5fe4d6b87e608b67c11cc00fd25 100644 +--- a/backend/open_webui/utils/middleware.py ++++ b/backend/open_webui/utils/middleware.py +@@ -144,6 +144,9 @@ from open_webui.utils.task import ( + tools_function_calling_generation_template, + ) + from open_webui.utils.tool_approval import complete_tool_call, pending_tool_calls ++from open_webui.utils.telemetry.genai.tools import traced_tool ++from open_webui.utils.telemetry.genai.tracer import GenAITracer ++from open_webui.utils.tool_approval import complete_tool_call, pending_tool_calls + from open_webui.utils.tool_search import apply_tool_search + from open_webui.utils.tools import ( + connect_mcp_server, +@@ -157,6 +160,7 @@ from starlette.responses import JSONResponse, Response, StreamingResponse + + logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) + log = logging.getLogger(__name__) ++ai_tracer = GenAITracer() + + + def _is_tool_result_error(value: Any) -> bool: +@@ -3481,11 +3485,11 @@ async def execute_tool_call(form_data, metadata, event_caller, tool_call): + ): + return params, 'Error: tool call was not approved.', tool, tool_type, direct_tool + +- if direct_tool: +- if not event_caller: +- result = 'Error: Browser session is not connected for this direct tool.' +- else: +- result = await event_caller( ++ async def invoke_tool(): ++ if direct_tool: ++ if not event_caller: ++ return 'Error: Browser session is not connected for this direct tool.' ++ return await event_caller( + { + 'type': 'execute:tool', + 'data': { +@@ -3497,7 +3501,6 @@ async def execute_tool_call(form_data, metadata, event_caller, tool_call): + }, + } + ) +- else: + function = await get_updated_tool_function( + function=tool['callable'], + extra_params={ +@@ -3505,7 +3508,17 @@ async def execute_tool_call(form_data, metadata, event_caller, tool_call): + '__files__': metadata.get('files', []), + }, + ) +- result = await function(**params) ++ return await function(**params) ++ ++ result = await traced_tool( ++ ai_tracer, ++ name=name, ++ call_id=tool_call.get('id', ''), ++ tool_type=tool_type, ++ arguments=params, ++ direct=direct_tool, ++ call=invoke_tool, ++ ) + except Exception as e: + result = {'error': str(e)} + return params, result, tool, tool_type, direct_tool +diff --git a/backend/open_webui/utils/telemetry/genai/README.md b/backend/open_webui/utils/telemetry/genai/README.md +new file mode 100644 +index 0000000000000000000000000000000000000000..1256af32839fc7c3d4fb68539279dbe484fc6202 +--- /dev/null ++++ b/backend/open_webui/utils/telemetry/genai/README.md +@@ -0,0 +1,99 @@ ++# OpenTelemetry GenAI telemetry ++ ++This package emits vendor-neutral OpenTelemetry GenAI spans. Structural ++attributes are emitted by default; content capture is opt-in and each content ++value is truncated to `OTEL_GENAI_CONTENT_MAX_LENGTH` (default `2000`). ++ ++## Environment variables ++ ++The following `OTEL_GENAI_*` variables are read by the semantic layer. Boolean ++values accept `1`, `true`, `yes`, or `on` (case-insensitive). ++ ++| Variable | Default | Captures | ++| --- | --- | --- | ++| `OTEL_GENAI_ENABLED` | `true` | Enables semantic span creation | ++| `OTEL_GENAI_CAPTURE_INPUTS` | `false` | Model input messages | ++| `OTEL_GENAI_CAPTURE_OUTPUTS` | `false` | Model output messages | ++| `OTEL_GENAI_CAPTURE_SYSTEM_INSTRUCTIONS` | `false` | System instructions | ++| `OTEL_GENAI_CAPTURE_TOOL_ARGUMENTS` | `false` | Tool arguments | ++| `OTEL_GENAI_CAPTURE_TOOL_RESULTS` | `false` | Tool results | ++| `OTEL_GENAI_CAPTURE_RETRIEVAL_QUERY` | `false` | Retrieval queries | ++| `OTEL_GENAI_CAPTURE_RETRIEVAL_DOCUMENTS` | `false` | Retrieved document content | ++| `OTEL_GENAI_CAPTURE_EMBEDDING_TEXT` | `false` | Text sent to embedding providers | ++| `OTEL_GENAI_CAPTURE_EMBEDDING_VECTORS` | `false` | Embedding vectors | ++| `OTEL_GENAI_CONTENT_MAX_LENGTH` | `2000` | Maximum characters for captured content | ++ ++The OTLP destination is configured with the standard OpenTelemetry variables, ++including `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`, and ++`OTEL_EXPORTER_OTLP_INSECURE`. No vendor-specific exporter or attribute is ++required. ++ ++## Protected content-capture verification ++ ++Content capture is disabled by default. For a protected local receiver only, ++enable the fields needed by the test and keep the length cap in place: ++ ++```bash ++OTEL_GENAI_CAPTURE_INPUTS=true \ ++OTEL_GENAI_CAPTURE_OUTPUTS=true \ ++OTEL_GENAI_CAPTURE_TOOL_ARGUMENTS=true \ ++OTEL_GENAI_CAPTURE_TOOL_RESULTS=true \ ++OTEL_GENAI_CAPTURE_RETRIEVAL_QUERY=true \ ++OTEL_GENAI_CAPTURE_RETRIEVAL_DOCUMENTS=true \ ++OTEL_GENAI_CONTENT_MAX_LENGTH=2000 ++``` ++ ++Buffered and streamed model outputs are emitted on `gen_ai.output.messages`. ++Tool arguments/results, retrieval query/document content, embedding text, and ++embedding vectors each remain independently controlled by their corresponding ++flag. Do not enable these flags for an untrusted or production telemetry ++destination without an explicit data-handling review. ++ ++## M10 interoperability procedure ++ ++Use the M9 `rag_rerank_tool` scenario as the representative trace: a chat ++workflow with retrieval, reranking, and tool execution. Run it with the same ++Open WebUI build and change only `OTEL_EXPORTER_OTLP_ENDPOINT` between ++backends. Keep content capture disabled unless the destination is a protected ++local test system. ++ ++1. Start one of the following OTLP-compatible destinations and record its OTLP ++ HTTP endpoint: ++ ++ - Local Phoenix: `http://localhost:6006/v1/traces`. ++ - Local or hosted Langfuse: use the project's OTLP HTTP endpoint, commonly ++ `https:///api/public/otel/v1/traces`, together with its documented ++ `OTEL_EXPORTER_OTLP_HEADERS` authentication value. ++ - Generic Jaeger or Tempo: use the instance's OTLP HTTP `/v1/traces` ++ endpoint. ++ ++2. Run Open WebUI with the endpoint override, for example: ++ ++ ```bash ++ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:6006/v1/traces \ ++ WEBUI_SECRET_KEY=test-secret-key \ ++ uv run open-webui serve ++ ``` ++ ++ For gRPC-only receivers, use the receiver's OTLP endpoint and set ++ `OTEL_EXPORTER_OTLP_PROTOCOL=grpc`; otherwise the HTTP exporter is used. ++ ++3. Exercise the `rag_rerank_tool` flow: submit a chat using a knowledge source, ++ allow reranking, and invoke a tool. Verify one trace contains the workflow, ++ inference, retrieval, rerank, and tool spans. Repeat step 2 for Phoenix, ++ Langfuse, and Jaeger/Tempo by changing only ++ `OTEL_EXPORTER_OTLP_ENDPOINT` (and, where required, the receiver's standard ++ OTLP headers). ++ ++### Known differences ++ ++- Backend UIs use different names and nesting displays for the same OTLP ++ spans. The conformance contract is the span topology and standard semantic ++ attributes, not a particular vendor's screen layout. ++- Phoenix, Langfuse, Jaeger, and Tempo may omit or flatten attributes they do ++ not index or render. This is a presentation/indexing difference; no ++ backend-specific code belongs in this repository. ++- Langfuse may require authentication headers and may display GenAI spans in ++ its generation view only after recognizing the standard GenAI attributes. ++- Jaeger and Tempo generally show the generic trace tree and attributes but do ++ not provide vendor-specific GenAI generation summaries. +diff --git a/backend/open_webui/utils/telemetry/genai/__init__.py b/backend/open_webui/utils/telemetry/genai/__init__.py +new file mode 100644 +index 0000000000000000000000000000000000000000..f88b66029173219948478d30058fa87726904c1b +--- /dev/null ++++ b/backend/open_webui/utils/telemetry/genai/__init__.py +@@ -0,0 +1,4 @@ ++from open_webui.utils.telemetry.genai.config import GenAIConfig ++from open_webui.utils.telemetry.genai.tracer import GenAITracer ++ ++__all__ = ['GenAIConfig', 'GenAITracer'] +diff --git a/backend/open_webui/utils/telemetry/genai/config.py b/backend/open_webui/utils/telemetry/genai/config.py +new file mode 100644 +index 0000000000000000000000000000000000000000..9736c9ef8ef5fed6de84afe50925b2bdda82e6cb +--- /dev/null ++++ b/backend/open_webui/utils/telemetry/genai/config.py +@@ -0,0 +1,43 @@ ++import os ++from dataclasses import dataclass ++ ++ ++def _as_bool(value: str | None) -> bool: ++ return (value or '').strip().lower() in {'1', 'true', 'yes', 'on'} ++ ++ ++@dataclass(frozen=True) ++class GenAIConfig: ++ enabled: bool = True ++ capture_inputs: bool = False ++ capture_outputs: bool = False ++ capture_system_instructions: bool = False ++ capture_tool_arguments: bool = False ++ capture_tool_results: bool = False ++ capture_retrieval_query: bool = False ++ capture_retrieval_documents: bool = False ++ capture_embedding_text: bool = False ++ capture_embedding_vectors: bool = False ++ content_max_length: int = 2000 ++ retrieval_max_documents: int = 100 ++ exception_max_length: int = 2000 ++ ++ @classmethod ++ def from_env(cls) -> 'GenAIConfig': ++ try: ++ max_length = max(int(os.getenv('OTEL_GENAI_CONTENT_MAX_LENGTH', '2000')), 0) ++ except (TypeError, ValueError): ++ max_length = 2000 ++ return cls( ++ enabled=os.getenv('OTEL_GENAI_ENABLED', 'true').strip().lower() not in {'0', 'false', 'no', 'off'}, ++ capture_inputs=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_INPUTS')), ++ capture_outputs=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_OUTPUTS')), ++ capture_system_instructions=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_SYSTEM_INSTRUCTIONS')), ++ capture_tool_arguments=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_TOOL_ARGUMENTS')), ++ capture_tool_results=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_TOOL_RESULTS')), ++ capture_retrieval_query=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_RETRIEVAL_QUERY')), ++ capture_retrieval_documents=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_RETRIEVAL_DOCUMENTS')), ++ capture_embedding_text=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_EMBEDDING_TEXT')), ++ capture_embedding_vectors=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_EMBEDDING_VECTORS')), ++ content_max_length=max_length, ++ ) +diff --git a/backend/open_webui/utils/telemetry/genai/context.py b/backend/open_webui/utils/telemetry/genai/context.py +new file mode 100644 +index 0000000000000000000000000000000000000000..1c38e54850e5a8be02b8b2c14da35fb0cecd10fa +--- /dev/null ++++ b/backend/open_webui/utils/telemetry/genai/context.py +@@ -0,0 +1,36 @@ ++import contextvars ++import logging ++ ++from open_webui.utils.telemetry.genai import semconv ++from opentelemetry.trace import Span ++ ++logger = logging.getLogger(__name__) ++_context = contextvars.ContextVar('openwebui_genai_context', default={}) ++ ++ ++def set_context(*, chat_id=None, message_id=None, conversation_id=None, purpose='primary'): ++ values = { ++ 'chat_id': chat_id, ++ 'message_id': message_id, ++ 'conversation_id': conversation_id, ++ 'purpose': purpose, ++ } ++ _context.set(values) ++ return values ++ ++ ++def get_context(): ++ return dict(_context.get()) ++ ++ ++def apply_context(span: Span, values=None): ++ values = values or get_context() ++ attributes = { ++ semconv.GEN_AI_CONVERSATION_ID: values.get('conversation_id') or values.get('chat_id'), ++ semconv.OPENWEBUI_MESSAGE_ID: values.get('message_id'), ++ semconv.OPENWEBUI_OPERATION_PURPOSE: values.get('purpose', 'primary'), ++ } ++ try: ++ span.set_attributes({key: value for key, value in attributes.items() if value is not None}) ++ except Exception: ++ logger.exception('Unable to apply GenAI span context') +diff --git a/backend/open_webui/utils/telemetry/genai/extractors.py b/backend/open_webui/utils/telemetry/genai/extractors.py +new file mode 100644 +index 0000000000000000000000000000000000000000..331c66c96b528b30331342090209dbf8a715a44c +--- /dev/null ++++ b/backend/open_webui/utils/telemetry/genai/extractors.py +@@ -0,0 +1,38 @@ ++import json ++ ++from open_webui.utils.telemetry.genai.config import GenAIConfig ++ ++ ++def _truncate(value, limit: int): ++ if value is None: ++ return None ++ if not isinstance(value, str): ++ value = json.dumps(value, ensure_ascii=False, default=str) ++ return value[:limit] ++ ++ ++def capture(value, *, enabled: bool, config: GenAIConfig): ++ if not enabled: ++ return None ++ return _truncate(value, config.content_max_length) ++ ++ ++def capture_attributes(attributes: dict, config: GenAIConfig) -> dict: ++ flags = { ++ 'inputs': config.capture_inputs, ++ 'outputs': config.capture_outputs, ++ 'system_instructions': config.capture_system_instructions, ++ 'tool_arguments': config.capture_tool_arguments, ++ 'tool_result': config.capture_tool_results, ++ 'retrieval_query': config.capture_retrieval_query, ++ 'retrieval_documents': config.capture_retrieval_documents, ++ 'embedding_text': config.capture_embedding_text, ++ 'embedding_vectors': config.capture_embedding_vectors, ++ } ++ captured = {} ++ for name, value in attributes.items(): ++ if name in flags: ++ value = capture(value, enabled=flags[name], config=config) ++ if value is not None: ++ captured[name] = value ++ return captured +diff --git a/backend/open_webui/utils/telemetry/genai/inference.py b/backend/open_webui/utils/telemetry/genai/inference.py +new file mode 100644 +index 0000000000000000000000000000000000000000..51704bfca6dbe3cc0ac9cbdbcd4b382827b4808e +--- /dev/null ++++ b/backend/open_webui/utils/telemetry/genai/inference.py +@@ -0,0 +1,53 @@ ++from collections.abc import Awaitable, Callable ++ ++from open_webui.utils.telemetry.genai.purpose import purpose_for_task ++from open_webui.utils.telemetry.genai.streaming import wrap_stream ++ ++ ++async def traced_inference( ++ tracer, ++ *, ++ provider: str, ++ request_payload: dict, ++ call: Callable[[], Awaitable], ++): ++ metadata = request_payload.get('metadata') or {} ++ with tracer.inference( ++ 'chat_completion', ++ provider=provider, ++ request_model=request_payload.get('model'), ++ inputs=request_payload.get('messages'), ++ temperature=request_payload.get('temperature'), ++ top_p=request_payload.get('top_p'), ++ max_tokens=request_payload.get('max_tokens', request_payload.get('max_completion_tokens')), ++ seed=request_payload.get('seed'), ++ purpose=purpose_for_task(metadata.get('task')), ++ _defer_end=True, ++ ) as span: ++ response = await call() ++ body_iterator = getattr(response, 'body_iterator', None) ++ if body_iterator is not None: ++ chunks = [] ++ captured_length = 0 ++ ++ def collect(item): ++ nonlocal captured_length ++ if tracer.config.capture_outputs and captured_length < tracer.config.content_max_length: ++ if isinstance(item, bytes): ++ item = item.decode('utf-8', errors='replace') ++ elif not isinstance(item, str): ++ item = str(item) ++ remaining = tracer.config.content_max_length - captured_length ++ chunk = item[:remaining] ++ chunks.append(chunk) ++ captured_length += len(chunk) ++ ++ response.body_iterator = wrap_stream( ++ span, ++ body_iterator, ++ on_finish=lambda: tracer.finish_stream(span, chunks), ++ on_item=collect, ++ ) ++ else: ++ tracer.finish_inference(span, response) ++ return response +diff --git a/backend/open_webui/utils/telemetry/genai/openinference.py b/backend/open_webui/utils/telemetry/genai/openinference.py +new file mode 100644 +index 0000000000000000000000000000000000000000..fdf354d613e23a8228d39fb680539b2a29b25839 +--- /dev/null ++++ b/backend/open_webui/utils/telemetry/genai/openinference.py +@@ -0,0 +1,15 @@ ++from open_webui.utils.telemetry.genai import semconv ++ ++_KINDS = { ++ 'rerank': 'RERANKER', ++ 'tool': 'TOOL', ++ 'embedding': 'EMBEDDING', ++ 'retrieval': 'RETRIEVER', ++ 'inference': 'LLM', ++ 'workflow': 'CHAIN', ++} ++ ++ ++def attributes_for(kind: str) -> dict: ++ value = _KINDS.get(kind) ++ return {semconv.OPENINFERENCE_SPAN_KIND: value} if value else {} +diff --git a/backend/open_webui/utils/telemetry/genai/purpose.py b/backend/open_webui/utils/telemetry/genai/purpose.py +new file mode 100644 +index 0000000000000000000000000000000000000000..eb20addd9252458501b9b34f678fceda166ed882 +--- /dev/null ++++ b/backend/open_webui/utils/telemetry/genai/purpose.py +@@ -0,0 +1,10 @@ ++TASK_PURPOSES = { ++ 'title_generation': 'title_generation', ++ 'follow_up_generation': 'followup_generation', ++ 'tags_generation': 'tags_generation', ++ 'query_generation': 'retrieval_query', ++} ++ ++ ++def purpose_for_task(task) -> str: ++ return TASK_PURPOSES.get(str(task), 'primary') +diff --git a/backend/open_webui/utils/telemetry/genai/retrieval.py b/backend/open_webui/utils/telemetry/genai/retrieval.py +new file mode 100644 +index 0000000000000000000000000000000000000000..88620ed6d04db4d7d0d0a40f8dc7638c6f48bdc6 +--- /dev/null ++++ b/backend/open_webui/utils/telemetry/genai/retrieval.py +@@ -0,0 +1,88 @@ ++import functools ++import inspect ++import json ++ ++from open_webui.utils.telemetry.genai import semconv ++from open_webui.utils.telemetry.genai.extractors import capture ++from open_webui.utils.telemetry.genai.openinference import attributes_for ++from open_webui.utils.telemetry.genai.tracer import GenAITracer ++ ++ ++def instrument_embedding(tracer: GenAITracer, embedding_function): ++ if embedding_function is None: ++ return None ++ ++ async def wrapped(query, *args, **kwargs): ++ with tracer.embedding('embedding', embedding_text=query) as span: ++ result = await embedding_function(query, *args, **kwargs) ++ if span.is_recording() and tracer.config.capture_embedding_vectors and result is not None: ++ value = capture(result, enabled=True, config=tracer.config) ++ if value is not None: ++ span.set_attribute(semconv.GEN_AI_EMBEDDINGS, value) ++ return result ++ ++ return wrapped ++ ++ ++def instrument_reranker(tracer: GenAITracer, reranking_function): ++ if reranking_function is None: ++ return None ++ ++ def wrapped(query, documents, *args, **kwargs): ++ with tracer.rerank('rerank', retrieval_query=query, **attributes_for('rerank')) as span: ++ result = reranking_function(query, documents, *args, **kwargs) ++ if span.is_recording(): ++ span.set_attribute(semconv.OPENWEBUI_RETRIEVAL_CANDIDATE_COUNT, len(documents)) ++ return result ++ ++ return wrapped ++ ++ ++def traced_retrieval(tracer: GenAITracer): ++ def decorator(function): ++ @functools.wraps(function) ++ async def wrapped(*args, **kwargs): ++ bound = inspect.signature(function).bind_partial(*args, **kwargs) ++ values = bound.arguments ++ queries = values.get('queries') or [] ++ query = queries[0] if isinstance(queries, list) and queries else queries ++ with tracer.retrieval( ++ 'retrieval', ++ operation_name='retrieval', ++ retrieval_query=query, ++ top_k=values.get('k'), ++ candidate_count=len(values.get('items') or []), ++ retrieval_mode='hybrid' if values.get('hybrid_search') else 'vector', ++ data_source=[ ++ item.get('collection_name') for item in values.get('items') or [] if item.get('collection_name') ++ ], ++ ) as span: ++ if 'embedding_function' in values: ++ values['embedding_function'] = instrument_embedding(tracer, values['embedding_function']) ++ result = await function(*bound.args, **bound.kwargs) ++ if span.is_recording() and isinstance(result, list): ++ documents = [] ++ for rank, source in enumerate(result[: tracer.config.retrieval_max_documents], 1): ++ metadata = source.get('metadata') or {} ++ distances = source.get('distances') or [] ++ entry = { ++ 'document_id': metadata.get('document_id') or metadata.get('id'), ++ 'file_id': metadata.get('file_id'), ++ 'chunk_id': metadata.get('chunk_id'), ++ 'rank': rank, ++ 'score': distances[rank - 1] if len(distances) >= rank else metadata.get('score'), ++ } ++ if tracer.config.capture_retrieval_documents: ++ entry['content'] = capture( ++ (source.get('document') or [''])[0], ++ enabled=True, ++ config=tracer.config, ++ ) ++ documents.append(json.dumps(entry, ensure_ascii=False, default=str)) ++ span.set_attribute(semconv.GEN_AI_RETRIEVAL_DOCUMENTS, documents) ++ span.set_attribute(semconv.OPENWEBUI_RETRIEVAL_SELECTED_COUNT, len(result)) ++ return result ++ ++ return wrapped ++ ++ return decorator +diff --git a/backend/open_webui/utils/telemetry/genai/semconv.py b/backend/open_webui/utils/telemetry/genai/semconv.py +new file mode 100644 +index 0000000000000000000000000000000000000000..eecb9c737dab53b1d4673c08b2c5212d6c55c73f +--- /dev/null ++++ b/backend/open_webui/utils/telemetry/genai/semconv.py +@@ -0,0 +1,84 @@ ++try: ++ from opentelemetry.semconv._incubating.attributes import gen_ai_attributes as _gen_ai ++except ImportError: # pragma: no cover - supports older OTel installations ++ _gen_ai = None ++ ++ ++def _official(name: str, fallback: str) -> str: ++ return getattr(_gen_ai, name, fallback) if _gen_ai is not None else fallback ++ ++ ++GEN_AI_OPERATION_NAME = _official('GEN_AI_OPERATION_NAME', 'gen_ai.operation.name') ++GEN_AI_PROVIDER_NAME = _official('GEN_AI_PROVIDER_NAME', 'gen_ai.provider.name') ++GEN_AI_REQUEST_MODEL = _official('GEN_AI_REQUEST_MODEL', 'gen_ai.request.model') ++GEN_AI_RESPONSE_MODEL = _official('GEN_AI_RESPONSE_MODEL', 'gen_ai.response.model') ++GEN_AI_RESPONSE_ID = _official('GEN_AI_RESPONSE_ID', 'gen_ai.response.id') ++GEN_AI_CONVERSATION_ID = _official('GEN_AI_CONVERSATION_ID', 'gen_ai.conversation.id') ++GEN_AI_USAGE_INPUT_TOKENS = _official('GEN_AI_USAGE_INPUT_TOKENS', 'gen_ai.usage.input_tokens') ++GEN_AI_USAGE_OUTPUT_TOKENS = _official('GEN_AI_USAGE_OUTPUT_TOKENS', 'gen_ai.usage.output_tokens') ++GEN_AI_TOOL_NAME = _official('GEN_AI_TOOL_NAME', 'gen_ai.tool.name') ++GEN_AI_TOOL_CALL_ID = _official('GEN_AI_TOOL_CALL_ID', 'gen_ai.tool.call.id') ++GEN_AI_TOOL_CALL_ARGUMENTS = _official('GEN_AI_TOOL_CALL_ARGUMENTS', 'gen_ai.tool.call.arguments') ++GEN_AI_TOOL_CALL_RESULT = _official('GEN_AI_TOOL_CALL_RESULT', 'gen_ai.tool.call.result') ++GEN_AI_TOOL_TYPE = _official('GEN_AI_TOOL_TYPE', 'gen_ai.tool.type') ++GEN_AI_RETRIEVAL_QUERY_TEXT = _official('GEN_AI_RETRIEVAL_QUERY_TEXT', 'gen_ai.retrieval.query.text') ++GEN_AI_RETRIEVAL_DOCUMENTS = _official('GEN_AI_RETRIEVAL_DOCUMENTS', 'gen_ai.retrieval.documents') ++GEN_AI_INPUT_MESSAGES = _official('GEN_AI_INPUT_MESSAGES', 'gen_ai.input.messages') ++GEN_AI_OUTPUT_MESSAGES = _official('GEN_AI_OUTPUT_MESSAGES', 'gen_ai.output.messages') ++GEN_AI_SYSTEM_INSTRUCTIONS = _official('GEN_AI_SYSTEM_INSTRUCTIONS', 'gen_ai.system.instructions') ++GEN_AI_RESPONSE_FINISH_REASONS = _official('GEN_AI_RESPONSE_FINISH_REASONS', 'gen_ai.response.finish_reasons') ++GEN_AI_REQUEST_TEMPERATURE = _official('GEN_AI_REQUEST_TEMPERATURE', 'gen_ai.request.temperature') ++GEN_AI_REQUEST_TOP_P = _official('GEN_AI_REQUEST_TOP_P', 'gen_ai.request.top_p') ++GEN_AI_REQUEST_MAX_TOKENS = _official('GEN_AI_REQUEST_MAX_TOKENS', 'gen_ai.request.max_tokens') ++GEN_AI_REQUEST_SEED = _official('GEN_AI_REQUEST_SEED', 'gen_ai.request.seed') ++GEN_AI_EMBEDDINGS = _official('GEN_AI_EMBEDDINGS', 'gen_ai.embeddings') ++ ++OPENWEBUI_MESSAGE_ID = 'openwebui.message.id' ++OPENWEBUI_OPERATION_PURPOSE = 'openwebui.operation.purpose' ++OPENWEBUI_TOOL_TYPE = 'openwebui.tool.type' ++OPENWEBUI_RETRIEVAL_MODE = 'openwebui.retrieval.mode' ++OPENWEBUI_RETRIEVAL_TOP_K = 'openwebui.retrieval.top_k' ++OPENWEBUI_RETRIEVAL_THRESHOLD = 'openwebui.retrieval.threshold' ++OPENWEBUI_RETRIEVAL_CANDIDATE_COUNT = 'openwebui.retrieval.candidate_count' ++OPENWEBUI_RETRIEVAL_SELECTED_COUNT = 'openwebui.retrieval.selected_count' ++OPENWEBUI_RETRIEVAL_DATA_SOURCE = 'openwebui.retrieval.data_source' ++OPENINFERENCE_SPAN_KIND = 'openinference.span.kind' ++ ++ALIASES = { ++ 'operation_name': GEN_AI_OPERATION_NAME, ++ 'provider': GEN_AI_PROVIDER_NAME, ++ 'request_model': GEN_AI_REQUEST_MODEL, ++ 'response_model': GEN_AI_RESPONSE_MODEL, ++ 'response_id': GEN_AI_RESPONSE_ID, ++ 'conversation_id': GEN_AI_CONVERSATION_ID, ++ 'input_tokens': GEN_AI_USAGE_INPUT_TOKENS, ++ 'output_tokens': GEN_AI_USAGE_OUTPUT_TOKENS, ++ 'tool_name': GEN_AI_TOOL_NAME, ++ 'tool_call_id': GEN_AI_TOOL_CALL_ID, ++ 'tool_arguments': GEN_AI_TOOL_CALL_ARGUMENTS, ++ 'tool_result': GEN_AI_TOOL_CALL_RESULT, ++ 'retrieval_query': GEN_AI_RETRIEVAL_QUERY_TEXT, ++ 'retrieval_documents': GEN_AI_RETRIEVAL_DOCUMENTS, ++ 'inputs': GEN_AI_INPUT_MESSAGES, ++ 'outputs': GEN_AI_OUTPUT_MESSAGES, ++ 'system_instructions': GEN_AI_SYSTEM_INSTRUCTIONS, ++ 'finish_reasons': GEN_AI_RESPONSE_FINISH_REASONS, ++ 'temperature': GEN_AI_REQUEST_TEMPERATURE, ++ 'top_p': GEN_AI_REQUEST_TOP_P, ++ 'max_tokens': GEN_AI_REQUEST_MAX_TOKENS, ++ 'seed': GEN_AI_REQUEST_SEED, ++ 'embedding_vectors': GEN_AI_EMBEDDINGS, ++ 'message_id': OPENWEBUI_MESSAGE_ID, ++ 'purpose': OPENWEBUI_OPERATION_PURPOSE, ++ 'tool_type': GEN_AI_TOOL_TYPE, ++ 'retrieval_mode': OPENWEBUI_RETRIEVAL_MODE, ++ 'top_k': OPENWEBUI_RETRIEVAL_TOP_K, ++ 'threshold': OPENWEBUI_RETRIEVAL_THRESHOLD, ++ 'candidate_count': OPENWEBUI_RETRIEVAL_CANDIDATE_COUNT, ++ 'selected_count': OPENWEBUI_RETRIEVAL_SELECTED_COUNT, ++ 'data_source': OPENWEBUI_RETRIEVAL_DATA_SOURCE, ++} ++ ++ ++def attribute_key(name: str) -> str: ++ return ALIASES.get(name, name) +diff --git a/backend/open_webui/utils/telemetry/genai/streaming.py b/backend/open_webui/utils/telemetry/genai/streaming.py +new file mode 100644 +index 0000000000000000000000000000000000000000..aa0d3bfbaa772333d4932e51a76681806ae90bb0 +--- /dev/null ++++ b/backend/open_webui/utils/telemetry/genai/streaming.py +@@ -0,0 +1,46 @@ ++import asyncio ++import logging ++ ++from open_webui.utils.telemetry.genai.config import GenAIConfig ++from opentelemetry.trace import StatusCode ++ ++logger = logging.getLogger(__name__) ++ ++ ++async def wrap_stream(span, async_iterator, *, on_item=None, on_finish=None): # noqa: C901 ++ ended = False ++ try: ++ async for item in async_iterator: ++ if on_item is not None: ++ try: ++ on_item(item) ++ except Exception: ++ logger.exception('Unable to capture GenAI stream item') ++ yield item ++ span.set_status(StatusCode.OK) ++ except asyncio.CancelledError: ++ span.set_status(StatusCode.ERROR) ++ raise ++ except BaseException as exc: ++ if span.is_recording(): ++ span.add_event( ++ 'exception', ++ { ++ 'exception.type': type(exc).__name__, ++ 'exception.message': str(exc)[: GenAIConfig().exception_max_length], ++ }, ++ ) ++ span.set_status(StatusCode.ERROR) ++ raise ++ finally: ++ if on_finish is not None: ++ try: ++ on_finish() ++ except Exception: ++ logger.exception('Unable to capture GenAI stream output') ++ if not ended: ++ ended = True ++ try: ++ span.end() ++ except Exception: ++ logger.exception('Unable to end GenAI streaming span') +diff --git a/backend/open_webui/utils/telemetry/genai/tools.py b/backend/open_webui/utils/telemetry/genai/tools.py +new file mode 100644 +index 0000000000000000000000000000000000000000..62572567c876585a95e359bd2df73dbb4e2c3044 +--- /dev/null ++++ b/backend/open_webui/utils/telemetry/genai/tools.py +@@ -0,0 +1,39 @@ ++from collections.abc import Awaitable, Callable ++ ++from open_webui.utils.telemetry.genai import semconv ++from open_webui.utils.telemetry.genai.tracer import GenAITracer ++ ++ ++def classify_tool(tool_type: str | None, direct: bool = False) -> str: ++ normalized = (tool_type or '').lower() ++ if normalized in {'mcp', 'openapi', 'external', 'action', 'terminal', 'builtin', 'function'}: ++ return normalized ++ return 'builtin' if direct else 'function' ++ ++ ++async def traced_tool( ++ tracer: GenAITracer, ++ *, ++ name: str, ++ call_id: str | None, ++ tool_type: str | None, ++ arguments, ++ direct: bool, ++ call: Callable[[], Awaitable], ++): ++ with tracer.tool( ++ 'execute_tool', ++ operation_name='execute_tool', ++ tool_name=name, ++ tool_call_id=call_id, ++ tool_type=classify_tool(tool_type, direct), ++ tool_arguments=arguments, ++ ) as span: ++ result = await call() ++ if span.is_recording() and tracer.config.capture_tool_results and result is not None: ++ from open_webui.utils.telemetry.genai.extractors import capture ++ ++ value = capture(result, enabled=True, config=tracer.config) ++ if value is not None: ++ span.set_attribute(semconv.GEN_AI_TOOL_CALL_RESULT, value) ++ return result +diff --git a/backend/open_webui/utils/telemetry/genai/tracer.py b/backend/open_webui/utils/telemetry/genai/tracer.py +new file mode 100644 +index 0000000000000000000000000000000000000000..7dd74a02da250d6e51ddf4d27679d1e0a7ea57f4 +--- /dev/null ++++ b/backend/open_webui/utils/telemetry/genai/tracer.py +@@ -0,0 +1,156 @@ ++import logging ++from contextlib import contextmanager ++ ++from open_webui.utils.telemetry.genai import context, extractors, semconv ++from open_webui.utils.telemetry.genai import openinference as oi ++from open_webui.utils.telemetry.genai.config import GenAIConfig ++from opentelemetry import trace ++from opentelemetry.trace import StatusCode ++ ++logger = logging.getLogger(__name__) ++ ++ ++def _record_exception(span, exc, limit): ++ if not span.is_recording(): ++ return ++ span.add_event( ++ 'exception', ++ { ++ 'exception.type': type(exc).__name__, ++ 'exception.message': str(exc)[:limit], ++ }, ++ ) ++ ++ ++class GenAITracer: ++ def __init__(self, tracer=None, config: GenAIConfig | None = None): ++ self.tracer = tracer or trace.get_tracer(__name__) ++ self.config = config or GenAIConfig.from_env() ++ ++ def workflow(self, name='invoke_workflow', **attrs): ++ return self._span('workflow', name, **attrs) ++ ++ def inference(self, name='inference', **attrs): ++ return self._span('inference', name, **attrs) ++ ++ def embedding(self, name='embedding', **attrs): ++ return self._span('embedding', name, **attrs) ++ ++ def retrieval(self, name='retrieval', **attrs): ++ return self._span('retrieval', name, **attrs) ++ ++ def rerank(self, name='rerank', **attrs): ++ return self._span('rerank', name, **attrs) ++ ++ def tool(self, name='execute_tool', **attrs): ++ return self._span('tool', name, **attrs) ++ ++ def finish_inference(self, span, response): ++ try: ++ if span.is_recording() and isinstance(response, dict): ++ usage = response.get('usage') or {} ++ attributes = { ++ semconv.GEN_AI_RESPONSE_MODEL: response.get('model'), ++ semconv.GEN_AI_RESPONSE_ID: response.get('id'), ++ semconv.GEN_AI_USAGE_INPUT_TOKENS: usage.get('prompt_tokens', usage.get('input_tokens')), ++ semconv.GEN_AI_USAGE_OUTPUT_TOKENS: usage.get('completion_tokens', usage.get('output_tokens')), ++ semconv.GEN_AI_RESPONSE_FINISH_REASONS: [ ++ choice.get('finish_reason') ++ for choice in response.get('choices', []) ++ if isinstance(choice, dict) and choice.get('finish_reason') is not None ++ ], ++ } ++ span.set_attributes({key: value for key, value in attributes.items() if value is not None}) ++ if self.config.capture_outputs: ++ output = _response_output(response) ++ captured = extractors.capture(output, enabled=True, config=self.config) ++ if captured is not None: ++ span.set_attribute(semconv.GEN_AI_OUTPUT_MESSAGES, captured) ++ span.end() ++ except Exception: ++ logger.exception('Unable to finish GenAI inference span') ++ ++ def finish_stream(self, span, chunks): ++ try: ++ if span.is_recording() and self.config.capture_outputs and chunks: ++ captured = extractors.capture(''.join(chunks), enabled=True, config=self.config) ++ if captured is not None: ++ span.set_attribute(semconv.GEN_AI_OUTPUT_MESSAGES, captured) ++ except Exception: ++ logger.exception('Unable to finish GenAI streaming span') ++ ++ @contextmanager ++ # The lifecycle guard intentionally handles independent failure points. ++ def _span(self, kind, name, **attrs): # noqa: C901 ++ if not self.config.enabled: ++ yield trace.INVALID_SPAN ++ return ++ defer_end = attrs.pop('_defer_end', False) ++ span = None ++ token = None ++ failed = False ++ try: ++ try: ++ span = self.tracer.start_span(name) ++ except Exception: ++ logger.exception('Unable to start GenAI span') ++ yield trace.INVALID_SPAN ++ return ++ ++ try: ++ token = trace.use_span(span, end_on_exit=False) ++ token.__enter__() ++ if span.is_recording(): ++ safe_attrs = extractors.capture_attributes(attrs, self.config) ++ resolved = { ++ semconv.attribute_key(key): value for key, value in safe_attrs.items() if value is not None ++ } ++ resolved.update(oi.attributes_for(kind)) ++ if resolved: ++ span.set_attributes(resolved) ++ if kind == 'workflow': ++ context.apply_context(span, attrs) ++ context.set_context( ++ chat_id=attrs.get('chat_id'), ++ message_id=attrs.get('message_id'), ++ conversation_id=attrs.get('conversation_id'), ++ purpose=attrs.get('purpose', 'primary'), ++ ) ++ except Exception: ++ logger.exception('Unable to initialize GenAI span') ++ ++ try: ++ yield span ++ except BaseException as exc: ++ failed = True ++ try: ++ _record_exception(span, exc, self.config.exception_max_length) ++ span.set_status(StatusCode.ERROR) ++ except Exception: ++ logger.exception('Unable to record GenAI span exception') ++ raise ++ finally: ++ if token is not None: ++ try: ++ token.__exit__(None, None, None) ++ except Exception: ++ logger.exception('Unable to detach GenAI span context') ++ if span is not None and (not defer_end or failed): ++ try: ++ span.end() ++ except Exception: ++ logger.exception('Unable to end GenAI span') ++ ++ ++def _response_output(response): ++ choices = response.get('choices') or [] ++ messages = [] ++ for choice in choices: ++ if not isinstance(choice, dict): ++ continue ++ message = choice.get('message') or choice.get('text') ++ if message is not None: ++ messages.append(message) ++ if messages: ++ return messages ++ return response.get('output') or response.get('response') +diff --git a/backend/open_webui/utils/telemetry/setup.py b/backend/open_webui/utils/telemetry/setup.py +index b280d4519afb464504e5dc1d7ab56a740beeabdd..c2a3b6528a031a0aab526bd3ca47ebaed27f3383 100644 +--- a/backend/open_webui/utils/telemetry/setup.py ++++ b/backend/open_webui/utils/telemetry/setup.py +@@ -31,7 +31,7 @@ def setup(app: FastAPI, db_engine: Engine): + trace.set_tracer_provider(TracerProvider(resource=resource)) + + # Add basic auth header only if both username and password are not empty +- headers = [] ++ headers = None + if OTEL_BASIC_AUTH_USERNAME and OTEL_BASIC_AUTH_PASSWORD: + auth_string = f'{OTEL_BASIC_AUTH_USERNAME}:{OTEL_BASIC_AUTH_PASSWORD}' + auth_header = b64encode(auth_string.encode()).decode() diff --git a/backend/tests/test_asgi_middleware.py b/backend/tests/test_asgi_middleware.py new file mode 100644 -index 000000000..bd0db41ec +index 0000000000000000000000000000000000000000..233bcb3e4841abe5594b40b16a592c638954f8cc --- /dev/null +++ b/backend/tests/test_asgi_middleware.py @@ -0,0 +1,34 @@ @@ -300,7 +2753,7 @@ index 000000000..bd0db41ec +from starlette.routing import Route +from starlette.testclient import TestClient + -+from open_webui.utils.asgi_middleware import RedirectMiddleware ++from open_webui.utils.asgi_middleware import AppHTTPMiddleware + + +async def ok(_request): @@ -309,7 +2762,7 @@ index 000000000..bd0db41ec + +def build_client(*, root_path: str = '') -> TestClient: + app = Starlette(routes=[Route('/', ok), Route('/watch', ok)]) -+ app.add_middleware(RedirectMiddleware) ++ app.add_middleware(AppHTTPMiddleware) + return TestClient(app, root_path=root_path) + + @@ -331,7 +2784,7 @@ index 000000000..bd0db41ec + assert response.headers['location'] == '/?youtube=abc123' diff --git a/backend/tests/test_env.py b/backend/tests/test_env.py new file mode 100644 -index 000000000..0920a357e +index 0000000000000000000000000000000000000000..0920a357e1d28e4e36dbfa531e890f8cb3490d08 --- /dev/null +++ b/backend/tests/test_env.py @@ -0,0 +1,11 @@ @@ -346,8 +2799,33 @@ index 000000000..0920a357e + env_module = importlib.reload(env_module) + + assert env_module.WEBUI_SUBPATH == '/openwebui' +diff --git a/pyproject.toml b/pyproject.toml +index 202bf0a35266a98dbdb206a00d3dcf6ab683b074..c82c96cde5fa37614b8e144d3c815706c2ad5962 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -141,6 +141,20 @@ mariadb = [ + unstructured = [ + "unstructured==0.22.31", + ] ++observability = [ ++ "opentelemetry-api==1.42.1", ++ "opentelemetry-sdk==1.42.1", ++ "opentelemetry-exporter-otlp==1.42.1", ++ "opentelemetry-instrumentation==0.63b1", ++ "opentelemetry-instrumentation-fastapi==0.63b1", ++ "opentelemetry-instrumentation-sqlalchemy==0.63b1", ++ "opentelemetry-instrumentation-redis==0.63b1", ++ "opentelemetry-instrumentation-requests==0.63b1", ++ "opentelemetry-instrumentation-logging==0.63b1", ++ "opentelemetry-instrumentation-httpx==0.63b1", ++ "opentelemetry-instrumentation-aiohttp-client==0.63b1", ++ "opentelemetry-instrumentation-system-metrics==0.63b1", ++] + + all = [ + "psycopg2-binary==2.9.12", diff --git a/src/app.html b/src/app.html -index 667808e01..b711d8aa8 100644 +index 667808e01140741d52647707a55ef5dd803075e6..b711d8aa89b11331973d3ecf0b60ed702ae424e2 100644 --- a/src/app.html +++ b/src/app.html @@ -6,37 +6,51 @@ @@ -445,7 +2923,7 @@ index 667808e01..b711d8aa8 100644 fetchpriority="high" class="animate-pulse-fast" diff --git a/src/lib/components/NotificationToast.svelte b/src/lib/components/NotificationToast.svelte -index 3e7101dbc..29e7d859f 100644 +index 3e7101dbce111a93b61a9212c61a3422f2a000d6..29e7d859fbe6defff9388e2c67366729c3139766 100644 --- a/src/lib/components/NotificationToast.svelte +++ b/src/lib/components/NotificationToast.svelte @@ -74,7 +74,7 @@ @@ -458,7 +2936,7 @@ index 3e7101dbc..29e7d859f 100644 // Ensure the global state is reset after the sound finishes playingNotificationSound.set(false); diff --git a/src/lib/components/OnBoarding.svelte b/src/lib/components/OnBoarding.svelte -index c761dc193..cf287e376 100644 +index c761dc19338a5825332d4264389f23b690267926..cf287e376fb2856e75aa12d44e3de145c3a21d26 100644 --- a/src/lib/components/OnBoarding.svelte +++ b/src/lib/components/OnBoarding.svelte @@ -1,5 +1,6 @@ @@ -478,7 +2956,7 @@ index c761dc193..cf287e376 100644 alt="logo" /> diff --git a/src/lib/components/admin/Analytics.svelte b/src/lib/components/admin/Analytics.svelte -index bd33b3c37..0c5ec6cc6 100644 +index da69de19e11120577846c15ea2d8d537e80f844e..97404f3ed847da491ff9a7869530febbf2ec0e71 100644 --- a/src/lib/components/admin/Analytics.svelte +++ b/src/lib/components/admin/Analytics.svelte @@ -1,4 +1,5 @@ @@ -497,7 +2975,7 @@ index bd33b3c37..0c5ec6cc6 100644 loaded = true; }); diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte -index 423269a98..97cbac32e 100644 +index 22fea3afc797b9e02e2e2a1226b77efa60788598..98538f5084f7d175a58696ca52f74d191dd57c3b 100644 --- a/src/lib/components/admin/Analytics/Dashboard.svelte +++ b/src/lib/components/admin/Analytics/Dashboard.svelte @@ -1,4 +1,5 @@ @@ -506,7 +2984,7 @@ index 423269a98..97cbac32e 100644 import { onMount, getContext } from 'svelte'; import { models } from '$lib/stores'; import { -@@ -487,7 +488,7 @@ +@@ -512,7 +513,7 @@ // LICENSE covers this Open WebUI fallback logo. // Do not alter, remove, obscure, or replace it except as LICENSE permits: // https://docs.openwebui.com/license. @@ -515,7 +2993,7 @@ index 423269a98..97cbac32e 100644 }} /> {model.name} -@@ -595,7 +596,7 @@ +@@ -620,7 +621,7 @@ alt={user.name || 'User'} class="size-5 rounded-full object-cover shrink-0" on:error={(e) => { @@ -526,7 +3004,7 @@ index 423269a98..97cbac32e 100644 + diff --git a/src/lib/components/admin/Evaluations.svelte b/src/lib/components/admin/Evaluations.svelte -index 5a7bb7965..d2112a708 100644 +index 5a7bb7965eca4aed50835cdb62dd122e52cc5bdc..d2112a708da149dba153b3078907483e183cf995 100644 --- a/src/lib/components/admin/Evaluations.svelte +++ b/src/lib/components/admin/Evaluations.svelte @@ -1,5 +1,7 @@ @@ -736,7 +3214,7 @@ index 5a7bb7965..d2112a708 100644 class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex items-center gap-1.5 text-right transition select-none {selectedTab === 'feedback' diff --git a/src/lib/components/admin/Evaluations/FeedbackModal.svelte b/src/lib/components/admin/Evaluations/FeedbackModal.svelte -index e4a4905b2..e13c612bf 100644 +index ef977bcab27b3a2ef794ce2341b9fb9d75bd9bcf..c0dd71c7165bd5288907fda261b8fbe6d5e4a25b 100644 --- a/src/lib/components/admin/Evaluations/FeedbackModal.svelte +++ b/src/lib/components/admin/Evaluations/FeedbackModal.svelte @@ -1,4 +1,5 @@ @@ -755,7 +3233,7 @@ index e4a4905b2..e13c612bf 100644 target="_blank" > diff --git a/src/lib/components/admin/Evaluations/Leaderboard.svelte b/src/lib/components/admin/Evaluations/Leaderboard.svelte -index 68246cc8d..b980c3888 100644 +index 68246cc8dc2533fa47119d16dc4f5c4151370e58..b980c3888a68f6fc8f9bef8bc4e3d7db87433bdd 100644 --- a/src/lib/components/admin/Evaluations/Leaderboard.svelte +++ b/src/lib/components/admin/Evaluations/Leaderboard.svelte @@ -1,4 +1,5 @@ @@ -774,7 +3252,7 @@ index 68246cc8d..b980c3888 100644 /> diff --git a/src/lib/components/admin/Functions.svelte b/src/lib/components/admin/Functions.svelte -index d7f654704..d344ed7be 100644 +index 075cd053e20516fc4d5df2d1339e8eca8d2cfe81..f427389f9e8e417f015a18620f6b59039d63e450 100644 --- a/src/lib/components/admin/Functions.svelte +++ b/src/lib/components/admin/Functions.svelte @@ -2,6 +2,7 @@ @@ -821,7 +3299,7 @@ index d7f654704..d344ed7be 100644 }, { id: 'functions-import-link', -@@ -653,7 +654,7 @@ +@@ -659,7 +660,7 @@ {func} show={openFunctionMenuId === func.id} editHandler={() => { @@ -831,26 +3309,26 @@ index d7f654704..d344ed7be 100644 shareHandler={() => { shareHandler(func); diff --git a/src/lib/components/admin/Functions/FunctionEditor.svelte b/src/lib/components/admin/Functions/FunctionEditor.svelte -index 41f0c0fc0..a6c872a36 100644 +index d72061607920341fd6dd5710d2537c68240e991b..6a27cc2777cdc28652cccc0010c26a579bc5c9e6 100644 --- a/src/lib/components/admin/Functions/FunctionEditor.svelte +++ b/src/lib/components/admin/Functions/FunctionEditor.svelte @@ -1,4 +1,5 @@ - + diff --git a/src/lib/components/channel/Thread.svelte b/src/lib/components/channel/Thread.svelte -index 454c0ea89..2d5157d12 100644 +index b5516ae67b486c564bee9ec3f067ee171f44ba73..75300f186a5562feef903d6de358296d848feb70 100644 --- a/src/lib/components/channel/Thread.svelte +++ b/src/lib/components/channel/Thread.svelte @@ -1,4 +1,5 @@ @@ -1126,7 +3588,7 @@ index 454c0ea89..2d5157d12 100644 }; diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte -index fd0e1bbac..9579bc311 100644 +index b7b9252c036da81a53d2e73c40dc82e2606e94ec..ef399192837fff9af1d0ce2bfcca34939c36d80e 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -1,4 +1,5 @@ @@ -1135,7 +3597,7 @@ index fd0e1bbac..9579bc311 100644 import { v4 as uuidv4 } from 'uuid'; import { toast } from 'svelte-sonner'; -@@ -867,7 +868,7 @@ +@@ -921,7 +922,7 @@ messageInput?.focus({ preventScroll: true }); } else if (!embedded) { @@ -1144,7 +3606,19 @@ index fd0e1bbac..9579bc311 100644 } else { loading = false; console.warn('[note-chat] embedded load failed; clearing spinner', { -@@ -2139,7 +2140,7 @@ +@@ -1847,7 +1848,10 @@ + } + + const pageSubscribe = page.subscribe(async (p) => { +- if (p.url.pathname === '/' || p.url.pathname.startsWith('/folders/')) { ++ // Route match is base-prefixed: under WEBUI_SUBPATH the root is e.g. ++ // "/chat/", and a missed match here silently skips initNewChat() ++ // (dropping ?model=/&models=/&temporary-chat=). ++ if (p.url.pathname === `${base}/` || p.url.pathname.startsWith(`${base}/folders/`)) { + await tick(); + initNewChat(); + } +@@ -2600,7 +2604,7 @@ await showArtifacts.set(false); if (!embedded && $page.url.pathname.includes('/c/')) { @@ -1153,7 +3627,7 @@ index fd0e1bbac..9579bc311 100644 } autoScroll = true; -@@ -2276,7 +2277,7 @@ +@@ -2732,7 +2736,7 @@ error }); if (!embedded) { @@ -1162,7 +3636,7 @@ index fd0e1bbac..9579bc311 100644 } return null; }); -@@ -3685,7 +3686,7 @@ +@@ -4230,7 +4234,7 @@ }); await chatId.set(res.chat_id); if (!$temporaryChatEnabled && !embedded) { @@ -1171,7 +3645,7 @@ index fd0e1bbac..9579bc311 100644 await refreshChatList(localStorage.token); // Persist chat-level params (system prompt, advanced -@@ -3971,7 +3972,7 @@ +@@ -4491,7 +4495,7 @@ await chatId.set(_chatId); if (!embedded) { @@ -1180,25 +3654,25 @@ index fd0e1bbac..9579bc311 100644 } await tick(); -@@ -4110,7 +4111,7 @@ +@@ -4631,7 +4635,7 @@ try { - await archiveChatById(localStorage.token, id); + const res = await archiveChatById(localStorage.token, id); initNewChat(); - await goto('/'); + await goto(`${base}/`); await refreshChatList(localStorage.token, { refreshPinned: true }); await refreshFolderChatLists(); - toast.success($i18n.t('Chat archived.')); -@@ -4146,7 +4147,7 @@ + toast.success(res?.archived ? $i18n.t('Chat archived.') : $i18n.t('Chat unarchived.')); +@@ -4667,7 +4671,7 @@ const res = await deleteChatById(localStorage.token, id); if (res) { initNewChat(); - await goto('/'); + await goto(`${base}/`); await refreshChatList(localStorage.token, { refreshPinned: true }); + await refreshFolderChatLists(); allTags.set(await getAllTags(localStorage.token)); - toast.success($i18n.t('Chat deleted.')); -@@ -4368,7 +4369,7 @@ +@@ -4953,7 +4957,7 @@ chatId.set(savedChat.id); await refreshChatList(localStorage.token); @@ -1208,7 +3682,7 @@ index fd0e1bbac..9579bc311 100644 } } catch (error) { diff --git a/src/lib/components/chat/ChatPlaceholder.svelte b/src/lib/components/chat/ChatPlaceholder.svelte -index 469395e85..6ae5d75b5 100644 +index 469395e85c7ae658f775ac2999b58db55c5cc0ac..6ae5d75b55009f2e95f9f8a4d2e137596b0bc312 100644 --- a/src/lib/components/chat/ChatPlaceholder.svelte +++ b/src/lib/components/chat/ChatPlaceholder.svelte @@ -1,4 +1,5 @@ @@ -1227,7 +3701,7 @@ index 469395e85..6ae5d75b5 100644 /> diff --git a/src/lib/components/chat/FileNav/SqliteView.svelte b/src/lib/components/chat/FileNav/SqliteView.svelte -index 0a0ef852d..3bd0d0860 100644 +index 0a0ef852d1e8048af1a813dfc74aa7fdd5fdac85..3bd0d0860344c45ca274fc3f6d08abd8e576d292 100644 --- a/src/lib/components/chat/FileNav/SqliteView.svelte +++ b/src/lib/components/chat/FileNav/SqliteView.svelte @@ -1,6 +1,7 @@ @@ -1248,7 +3722,7 @@ index 0a0ef852d..3bd0d0860 100644 db = new SQL.Database(new Uint8Array(data)); diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte -index e6b746e3d..8de15e89d 100644 +index 002e5554b8698c77905a492cba55023299a30b7f..68cfb1ebdb71c18fb3f992c96d1af084f7f9a6ce 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -1,4 +1,5 @@ @@ -1257,7 +3731,7 @@ index e6b746e3d..8de15e89d 100644 import { resolveLocalizedFunction } from '$lib/utils/localizedContent'; import { functions as localizedFunctions } from '$lib/stores'; import DOMPurify from 'dompurify'; -@@ -1211,7 +1212,7 @@ +@@ -1230,7 +1231,7 @@ if (res) { // Clear the input content saved in session storage. sessionStorage.removeItem('chat-input'); @@ -1267,7 +3741,7 @@ index e6b746e3d..8de15e89d 100644 }; diff --git a/src/lib/components/chat/MessageInput/Commands/Models.svelte b/src/lib/components/chat/MessageInput/Commands/Models.svelte -index c159a4f8f..6c0071168 100644 +index c607c4dee73e6891837dcf754757581dc48126ec..d1581f44d79656a696fc637728650a89fe388a9c 100644 --- a/src/lib/components/chat/MessageInput/Commands/Models.svelte +++ b/src/lib/components/chat/MessageInput/Commands/Models.svelte @@ -1,4 +1,5 @@ @@ -1286,7 +3760,7 @@ index c159a4f8f..6c0071168 100644 />
diff --git a/src/lib/components/chat/MessageInput/TerminalMenu.svelte b/src/lib/components/chat/MessageInput/TerminalMenu.svelte -index 870fdc792..a6728236d 100644 +index 870fdc79247f3373048d46d414abb38b1b0030a2..a6728236d034712640ef0bbbb6a43865d713d78b 100644 --- a/src/lib/components/chat/MessageInput/TerminalMenu.svelte +++ b/src/lib/components/chat/MessageInput/TerminalMenu.svelte @@ -1,4 +1,5 @@ @@ -1296,7 +3770,7 @@ index 870fdc792..a6728236d 100644 import { diff --git a/src/lib/components/chat/Messages/Citations.svelte b/src/lib/components/chat/Messages/Citations.svelte -index e53a8753f..69c194dea 100644 +index e53a8753f74ba5fa55f399452e7da2bd0e772b1f..69c194dea24b04fd90060d06e1ae203eb2aefc32 100644 --- a/src/lib/components/chat/Messages/Citations.svelte +++ b/src/lib/components/chat/Messages/Citations.svelte @@ -1,4 +1,5 @@ @@ -1315,7 +3789,7 @@ index e53a8753f..69c194dea 100644 /> {/each} diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte -index 9497444be..0b075d4c8 100644 +index fa82a1f4278c19f3ebcd77e71206014a21f06ebd..fab7d788e9035b17b408cbd67b5d9995b89b263a 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte @@ -1,4 +1,5 @@ @@ -1324,7 +3798,7 @@ index 9497444be..0b075d4c8 100644 import type { Token } from 'marked'; import { LinkPreview } from 'bits-ui'; -@@ -95,13 +96,13 @@ +@@ -96,13 +97,13 @@ console.log('Clicked user mention', id); } else if (idType === 'M') { console.log('Clicked model mention', id); @@ -1341,7 +3815,7 @@ index 9497444be..0b075d4c8 100644 } else if (idType === 'T') { // Open thread diff --git a/src/lib/components/chat/ModelSelector/ModelItem.svelte b/src/lib/components/chat/ModelSelector/ModelItem.svelte -index b15041b08..3fcd717ab 100644 +index f6a40325f36c2f79b15f633f965bbe83db33d524..0c050c0f13f15277fd16793f943956fdbe4d249c 100644 --- a/src/lib/components/chat/ModelSelector/ModelItem.svelte +++ b/src/lib/components/chat/ModelSelector/ModelItem.svelte @@ -1,4 +1,5 @@ @@ -1350,7 +3824,7 @@ index b15041b08..3fcd717ab 100644 import { marked } from 'marked'; import { getContext, tick } from 'svelte'; -@@ -105,7 +106,7 @@ +@@ -106,7 +107,7 @@ // LICENSE covers this Open WebUI fallback logo. // Do not alter, remove, obscure, or replace it except as LICENSE permits: // https://docs.openwebui.com/license. @@ -1360,7 +3834,7 @@ index b15041b08..3fcd717ab 100644 /> diff --git a/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte b/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte -index 57fd390f6..2a8201779 100644 +index acf546d5f6547330be1250c1e3cc513c95061e35..10194fbf69ca0d21f834c1d9c65f97d48114d524 100644 --- a/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte +++ b/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte @@ -1,6 +1,7 @@ @@ -1371,7 +3845,7 @@ index 57fd390f6..2a8201779 100644 import Dropdown from '$lib/components/common/Dropdown.svelte'; import DropdownMenu from '$lib/components/common/DropdownMenu.svelte'; -@@ -56,7 +57,7 @@ +@@ -58,7 +59,7 @@ e.preventDefault(); if (model?.preset || model?.info?.base_model_id) { @@ -1381,7 +3855,7 @@ index 57fd390f6..2a8201779 100644 showSettings.set({ tab: 'admin:models', state: { id: model?.id ?? null } }); } diff --git a/src/lib/components/chat/ModelSelector/Selector.svelte b/src/lib/components/chat/ModelSelector/Selector.svelte -index 405f4863e..cb7f2610c 100644 +index 683acd5540aeb8f61139c445f3464abc0fc4a023..d94b5cd1715d00e2ed4f581d804343f26ad84b65 100644 --- a/src/lib/components/chat/ModelSelector/Selector.svelte +++ b/src/lib/components/chat/ModelSelector/Selector.svelte @@ -1,4 +1,5 @@ @@ -1391,7 +3865,7 @@ index 405f4863e..cb7f2610c 100644 import Fuse from 'fuse.js'; import { autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom'; diff --git a/src/lib/components/chat/Navbar.svelte b/src/lib/components/chat/Navbar.svelte -index 681a3675f..581c8340d 100644 +index 681a3675fca8a26441051e258ed962e427cd3a6a..581c8340d1a8d68d08795430bd023c3b83f29020 100644 --- a/src/lib/components/chat/Navbar.svelte +++ b/src/lib/components/chat/Navbar.svelte @@ -1,4 +1,5 @@ @@ -1410,7 +3884,7 @@ index 681a3675f..581c8340d 100644 // add 'temporary-chat=true' to the URL diff --git a/src/lib/components/chat/Placeholder.svelte b/src/lib/components/chat/Placeholder.svelte -index 603861eae..9a3af032b 100644 +index b2833b67c380dde8d418de368a237dfbfabb63e8..ed8c7510b28a6e8facc0e4bbd5df4d620fd2718b 100644 --- a/src/lib/components/chat/Placeholder.svelte +++ b/src/lib/components/chat/Placeholder.svelte @@ -1,4 +1,5 @@ @@ -1419,7 +3893,7 @@ index 603861eae..9a3af032b 100644 import { toast } from 'svelte-sonner'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; -@@ -169,7 +170,7 @@ +@@ -170,7 +171,7 @@ // LICENSE covers this Open WebUI fallback logo. // Do not alter, remove, obscure, or replace it except as LICENSE permits: // https://docs.openwebui.com/license. @@ -1429,7 +3903,7 @@ index 603861eae..9a3af032b 100644 /> diff --git a/src/lib/components/chat/Placeholder/ChatList.svelte b/src/lib/components/chat/Placeholder/ChatList.svelte -index 92f099ccd..f5f19c499 100644 +index 084cf8ad6ff897581e226f6f97bc5d6e142fd1d4..dddd81c362355022f194e7c416072c6e2afc9b76 100644 --- a/src/lib/components/chat/Placeholder/ChatList.svelte +++ b/src/lib/components/chat/Placeholder/ChatList.svelte @@ -1,4 +1,5 @@ @@ -1438,7 +3912,7 @@ index 92f099ccd..f5f19c499 100644 import { getContext } from 'svelte'; import type { Writable } from 'svelte/store'; -@@ -210,7 +211,7 @@ +@@ -211,7 +212,7 @@
diff --git a/src/lib/components/chat/Settings/DataControls.svelte b/src/lib/components/chat/Settings/DataControls.svelte -index 45bc8a303..488fcc4be 100644 +index 45bc8a3035e3fc4b8b565aa624b07bf8185e1476..488fcc4be42d4fd624f9caa8dd34ec679d25e59f 100644 --- a/src/lib/components/chat/Settings/DataControls.svelte +++ b/src/lib/components/chat/Settings/DataControls.svelte @@ -1,4 +1,5 @@ @@ -1476,7 +3950,7 @@ index 45bc8a303..488fcc4be 100644 toast.error(`${error}`); }); diff --git a/src/lib/components/chat/SettingsModal.svelte b/src/lib/components/chat/SettingsModal.svelte -index 2557290d4..6ae6d4337 100644 +index c20f170cd1fb29173bb9247dd2d11a1be14a2ae7..e482a5bd5e0768fce07988b4a2717a851229601a 100644 --- a/src/lib/components/chat/SettingsModal.svelte +++ b/src/lib/components/chat/SettingsModal.svelte @@ -6,6 +6,7 @@ @@ -1488,35 +3962,17 @@ index 2557290d4..6ae6d4337 100644 import type { Writable } from 'svelte/store'; import { toast } from 'svelte-sonner'; diff --git a/src/lib/components/chat/ShareChatModal.svelte b/src/lib/components/chat/ShareChatModal.svelte -index 67e7fa43b..b5f7114a6 100644 +index ab2bf7297e2079319917c9cd4965663014c28815..a566e50398ccf856dabc6747e978ed8a63e73d13 100644 --- a/src/lib/components/chat/ShareChatModal.svelte +++ b/src/lib/components/chat/ShareChatModal.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/(app)/admin/analytics/+page.svelte b/src/routes/(app)/admin/analytics/+page.svelte -index 302f4c426..2bda342d2 100644 +index 302f4c4261fa08ed53aafd197705736f81fb1492..2bda342d26c344865e5601d04c2e08229bd766c8 100644 --- a/src/routes/(app)/admin/analytics/+page.svelte +++ b/src/routes/(app)/admin/analytics/+page.svelte @@ -1,13 +1,14 @@ @@ -3725,7 +5791,7 @@ index 302f4c426..2bda342d2 100644 }); diff --git a/src/routes/(app)/admin/analytics/[tab]/+page.svelte b/src/routes/(app)/admin/analytics/[tab]/+page.svelte -index 302f4c426..2bda342d2 100644 +index 302f4c4261fa08ed53aafd197705736f81fb1492..2bda342d26c344865e5601d04c2e08229bd766c8 100644 --- a/src/routes/(app)/admin/analytics/[tab]/+page.svelte +++ b/src/routes/(app)/admin/analytics/[tab]/+page.svelte @@ -1,13 +1,14 @@ @@ -3746,7 +5812,7 @@ index 302f4c426..2bda342d2 100644 }); diff --git a/src/routes/(app)/admin/evaluations/+page.svelte b/src/routes/(app)/admin/evaluations/+page.svelte -index 8913071b7..bd205f5e1 100644 +index 8913071b7f2019638c89e9bc6fdea55c511fa80c..bd205f5e13bc097ef544cdd1d4b54932b184607c 100644 --- a/src/routes/(app)/admin/evaluations/+page.svelte +++ b/src/routes/(app)/admin/evaluations/+page.svelte @@ -1,8 +1,9 @@ @@ -3761,7 +5827,7 @@ index 8913071b7..bd205f5e1 100644 }); diff --git a/src/routes/(app)/admin/functions/create/+page.svelte b/src/routes/(app)/admin/functions/create/+page.svelte -index 553d67dce..aee34328e 100644 +index 6284948bc416af582d6eadcc8002f33bc68c0387..06a2531e034dc4eae05587437bf897570728b357 100644 --- a/src/routes/(app)/admin/functions/create/+page.svelte +++ b/src/routes/(app)/admin/functions/create/+page.svelte @@ -1,4 +1,5 @@ @@ -3770,7 +5836,7 @@ index 553d67dce..aee34328e 100644 import { toast } from 'svelte-sonner'; import { onMount, getContext } from 'svelte'; import { goto } from '$app/navigation'; -@@ -59,7 +60,7 @@ +@@ -60,7 +61,7 @@ ) ); @@ -3780,7 +5846,7 @@ index 553d67dce..aee34328e 100644 }; diff --git a/src/routes/(app)/admin/functions/edit/+page.svelte b/src/routes/(app)/admin/functions/edit/+page.svelte -index c36e74c1b..c15adbe91 100644 +index 1693a185321aec85f2e902105e2168ef3ba3aa21..294070a612ad8b6208b3e12af1c9fa998bece2ee 100644 --- a/src/routes/(app)/admin/functions/edit/+page.svelte +++ b/src/routes/(app)/admin/functions/edit/+page.svelte @@ -1,4 +1,5 @@ @@ -3789,7 +5855,7 @@ index c36e74c1b..c15adbe91 100644 import { toast } from 'svelte-sonner'; import { onMount, getContext } from 'svelte'; -@@ -74,7 +75,7 @@ +@@ -78,7 +79,7 @@ if (id) { func = await getFunctionById(localStorage.token, id).catch((error) => { toast.error(`${error}`); @@ -3799,7 +5865,7 @@ index c36e74c1b..c15adbe91 100644 }); diff --git a/src/routes/(app)/admin/settings/+page.svelte b/src/routes/(app)/admin/settings/+page.svelte -index e015a1c57..dffd96826 100644 +index e015a1c57f4d490a8aa4cd9c58bfbd08e8110341..dffd96826bc987f38d2a6a8a41b9756cc959818b 100644 --- a/src/routes/(app)/admin/settings/+page.svelte +++ b/src/routes/(app)/admin/settings/+page.svelte @@ -1,8 +1,9 @@ @@ -3814,7 +5880,7 @@ index e015a1c57..dffd96826 100644 }); diff --git a/src/routes/(app)/admin/users/+page.svelte b/src/routes/(app)/admin/users/+page.svelte -index ebf29a7ab..bc56c4f47 100644 +index ebf29a7ab5caf158bcc0fb867b712440e5812e6b..bc56c4f47ea86aa5d701f606ede6963c7a41d535 100644 --- a/src/routes/(app)/admin/users/+page.svelte +++ b/src/routes/(app)/admin/users/+page.svelte @@ -1,8 +1,9 @@ @@ -3829,59 +5895,26 @@ index ebf29a7ab..bc56c4f47 100644 }); diff --git a/src/routes/(app)/automations/+page.svelte b/src/routes/(app)/automations/+page.svelte -index 9dd9ffbdb..bbe93ec75 100644 +index 4e8c747c0be57e050e30bfa132aed0990a00cc87..26b8651c1e338592ca46df9e2390c77a3f4b187b 100644 --- a/src/routes/(app)/automations/+page.svelte +++ b/src/routes/(app)/automations/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/(app)/calendar/+page.svelte b/src/routes/(app)/calendar/+page.svelte -index b057c3a77..44ff634e4 100644 +index 5e8da41b1ad4e9c45410d6b659349be59658503a..2da2df9c668fd3d9d219ce27518711d063ddd9e9 100644 --- a/src/routes/(app)/calendar/+page.svelte +++ b/src/routes/(app)/calendar/+page.svelte @@ -2,6 +2,7 @@ @@ -3926,7 +5959,7 @@ index b057c3a77..44ff634e4 100644 import { WEBUI_NAME, mobile, showSidebar, user } from '$lib/stores'; import { getCalendars, -@@ -138,9 +139,9 @@ +@@ -139,9 +140,9 @@ const evt = e.detail; if (evt.meta?.automation_id) { if (evt.meta?.chat_id) { @@ -3939,7 +5972,7 @@ index b057c3a77..44ff634e4 100644 return; } diff --git a/src/routes/(app)/home/+layout.svelte b/src/routes/(app)/home/+layout.svelte -index 311a54900..6792dda19 100644 +index 311a54900567aafe20f9c7dd2f4fc54a94fe83d0..6792dda19890a774b3bf004f775876164959474f 100644 --- a/src/routes/(app)/home/+layout.svelte +++ b/src/routes/(app)/home/+layout.svelte @@ -1,4 +1,5 @@ @@ -3966,7 +5999,7 @@ index 311a54900..6792dda19 100644
diff --git a/src/routes/(app)/notes/+layout.svelte b/src/routes/(app)/notes/+layout.svelte -index 01ed3c7c1..f38c97f9f 100644 +index 01ed3c7c113f2520f7f7f10aad7a3c4c359189b0..f38c97f9f972ea9d2d8211279bffcdc29c244bd8 100644 --- a/src/routes/(app)/notes/+layout.svelte +++ b/src/routes/(app)/notes/+layout.svelte @@ -1,4 +1,5 @@ @@ -3985,45 +6018,27 @@ index 01ed3c7c1..f38c97f9f 100644 loaded = true; diff --git a/src/routes/(app)/notes/+page.svelte b/src/routes/(app)/notes/+page.svelte -index 838463942..95956a2d7 100644 +index 8bb6047296e71a7e2e14ca955b3320a2fef4d1ed..9a4f14034cf227685076d6e58237370da3101377 100644 --- a/src/routes/(app)/notes/+page.svelte +++ b/src/routes/(app)/notes/+page.svelte @@ -1,4 +1,5 @@ + import { getContext } from 'svelte'; + import type { Writable } from 'svelte/store'; + import type { i18n as I18n } from 'i18next'; diff --git a/src/routes/(app)/playground/+layout.svelte b/src/routes/(app)/playground/+layout.svelte -index 4550ebe88..183fe148d 100644 +index 4550ebe88a59bf1173a4667f693e5e9e03af0693..183fe148d3579d65babfd10a64c52ce00733e976 100644 --- a/src/routes/(app)/playground/+layout.svelte +++ b/src/routes/(app)/playground/+layout.svelte @@ -1,4 +1,5 @@ @@ -4068,7 +6083,7 @@ index 4550ebe88..183fe148d 100644 diff --git a/src/routes/(app)/workspace/+layout.svelte b/src/routes/(app)/workspace/+layout.svelte -index 11eed46a9..7bf6879da 100644 +index 52f728cab53096369502dd19bece2e95a8ea26ae..9c8c612a1cfd8123013cb033ca18aa92fda17d11 100644 --- a/src/routes/(app)/workspace/+layout.svelte +++ b/src/routes/(app)/workspace/+layout.svelte @@ -1,4 +1,5 @@ @@ -4115,7 +6130,7 @@ index 11eed46a9..7bf6879da 100644 + goto(`${base}/`, { replaceState: true }); } else if ( $page.url.pathname.includes('/tools') && - (!$config?.features?.enable_plugins || !$user?.permissions?.workspace?.tools) + (!$config?.features?.enable_tools || !$user?.permissions?.workspace?.tools) ) { - goto('/', { replaceState: true }); + goto(`${base}/`, { replaceState: true }); @@ -4171,7 +6186,7 @@ index 11eed46a9..7bf6879da 100644 {$i18n.t('Tools')} diff --git a/src/routes/(app)/workspace/+page.svelte b/src/routes/(app)/workspace/+page.svelte -index ef7edaf8f..88e5ee3a4 100644 +index 20a1a6fbd7a2dedfc35e8fc91fea423495bb56f3..b46257dcf980d41b1c84d771af577f84a46b5b64 100644 --- a/src/routes/(app)/workspace/+page.svelte +++ b/src/routes/(app)/workspace/+page.svelte @@ -1,4 +1,5 @@ @@ -4180,36 +6195,8 @@ index ef7edaf8f..88e5ee3a4 100644 import { goto } from '$app/navigation'; import { config, user } from '$lib/stores'; import { onMount } from 'svelte'; -@@ -6,20 +7,20 @@ - onMount(() => { - if ($user?.role !== 'admin') { - if ($user?.permissions?.workspace?.models) { -- goto('/workspace/models', { replaceState: true }); -+ goto(`${base}/workspace/models`, { replaceState: true }); - } else if ($user?.permissions?.workspace?.knowledge) { -- goto('/workspace/knowledge', { replaceState: true }); -+ goto(`${base}/workspace/knowledge`, { replaceState: true }); - } else if ($user?.permissions?.workspace?.prompts) { -- goto('/workspace/prompts', { replaceState: true }); -+ goto(`${base}/workspace/prompts`, { replaceState: true }); - } else if ($config?.features?.enable_plugins && $user?.permissions?.workspace?.tools) { -- goto('/workspace/tools', { replaceState: true }); -+ goto(`${base}/workspace/tools`, { replaceState: true }); - } else if ($user?.permissions?.workspace?.skills) { -- goto('/workspace/skills', { replaceState: true }); -+ goto(`${base}/workspace/skills`, { replaceState: true }); - } else { -- goto('/', { replaceState: true }); -+ goto(`${base}/`, { replaceState: true }); - } - } else { -- goto('/workspace/models', { replaceState: true }); -+ goto(`${base}/workspace/models`, { replaceState: true }); - } - }); - diff --git a/src/routes/(app)/workspace/functions/create/+page.svelte b/src/routes/(app)/workspace/functions/create/+page.svelte -index 932d28804..d3da353d9 100644 +index 932d28804014d16717a75c916c4301d62e74be55..d3da353d9203b317ab200ade4a4873142015d567 100644 --- a/src/routes/(app)/workspace/functions/create/+page.svelte +++ b/src/routes/(app)/workspace/functions/create/+page.svelte @@ -1,8 +1,9 @@ @@ -4224,7 +6211,7 @@ index 932d28804..d3da353d9 100644 }); diff --git a/src/routes/(app)/workspace/knowledge/create/+page.svelte b/src/routes/(app)/workspace/knowledge/create/+page.svelte -index b5e1b33ef..d93a9b8c5 100644 +index b5e1b33ef0b262586d81c5602e8efc0c3a32129d..d93a9b8c5d014e0fb6fdf6e1aa665f39b3f1e08e 100644 --- a/src/routes/(app)/workspace/knowledge/create/+page.svelte +++ b/src/routes/(app)/workspace/knowledge/create/+page.svelte @@ -1,5 +1,6 @@ @@ -4236,7 +6223,7 @@ index b5e1b33ef..d93a9b8c5 100644 - + diff --git a/src/routes/(app)/workspace/models/create/+page.svelte b/src/routes/(app)/workspace/models/create/+page.svelte -index 08fae9b00..dc88737c7 100644 +index 419007e361b6a168ded58bd791416ab5eeeb1caa..890cb41d936477f38c03a80e37702246381e842a 100644 --- a/src/routes/(app)/workspace/models/create/+page.svelte +++ b/src/routes/(app)/workspace/models/create/+page.svelte @@ -1,4 +1,5 @@ @@ -4264,7 +6251,7 @@ index 08fae9b00..dc88737c7 100644 /> {/key} diff --git a/src/routes/(app)/workspace/models/edit/+page.svelte b/src/routes/(app)/workspace/models/edit/+page.svelte -index 71ecc06e0..c265f8116 100644 +index 2cd6cf88121e3046b016e53a5792e90683013934..8c21d96c82c7dd7a5f8b693b6a453496081e97ca 100644 --- a/src/routes/(app)/workspace/models/edit/+page.svelte +++ b/src/routes/(app)/workspace/models/edit/+page.svelte @@ -1,4 +1,5 @@ @@ -4313,7 +6300,7 @@ index 71ecc06e0..c265f8116 100644 /> {/if} diff --git a/src/routes/(app)/workspace/prompts/[id]/+page.svelte b/src/routes/(app)/workspace/prompts/[id]/+page.svelte -index d16d235d4..8cbfb3535 100644 +index 6a10adca10b2b963669644f133a888bcf8412c39..ace75213fb69634c6c5d3647b59acb12b9c5f260 100644 --- a/src/routes/(app)/workspace/prompts/[id]/+page.svelte +++ b/src/routes/(app)/workspace/prompts/[id]/+page.svelte @@ -1,4 +1,5 @@ @@ -4322,7 +6309,7 @@ index d16d235d4..8cbfb3535 100644 import { toast } from 'svelte-sonner'; import { goto } from '$app/navigation'; import { onMount, getContext } from 'svelte'; -@@ -58,10 +59,10 @@ +@@ -54,10 +55,10 @@ access_grants: _prompt?.access_grants === undefined ? [] : _prompt?.access_grants }; } else { @@ -4336,7 +6323,7 @@ index d16d235d4..8cbfb3535 100644 }); diff --git a/src/routes/(app)/workspace/prompts/create/+page.svelte b/src/routes/(app)/workspace/prompts/create/+page.svelte -index c6c80aed7..9a6c400ba 100644 +index c6c80aed76be790567e9e44659f286a9396d8f55..9a6c400baeae82ff6d08387a3343aff534017271 100644 --- a/src/routes/(app)/workspace/prompts/create/+page.svelte +++ b/src/routes/(app)/workspace/prompts/create/+page.svelte @@ -1,5 +1,6 @@ @@ -4348,26 +6335,17 @@ index c6c80aed7..9a6c400ba 100644 - + diff --git a/src/routes/(app)/workspace/skills/create/+page.svelte b/src/routes/(app)/workspace/skills/create/+page.svelte -index 3985cea95..7e05ad669 100644 +index 72d4e591e2c363ad8533de93de294f1d8d67dc1c..f9a91916eeb513ab9e6d4ff94ade813963ad132b 100644 --- a/src/routes/(app)/workspace/skills/create/+page.svelte +++ b/src/routes/(app)/workspace/skills/create/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/(app)/workspace/tools/create/+page.svelte b/src/routes/(app)/workspace/tools/create/+page.svelte -index 8bcc66ce0..b58effed7 100644 +index a6f3e0344c0942025e83ea26e15b04a3e91e2e5c..ab4ab87a44e982386e9a5c406f7f0020eec365c9 100644 --- a/src/routes/(app)/workspace/tools/create/+page.svelte +++ b/src/routes/(app)/workspace/tools/create/+page.svelte @@ -1,4 +1,5 @@ @@ -4399,7 +6377,7 @@ index 8bcc66ce0..b58effed7 100644 import { goto } from '$app/navigation'; import { createNewTool, getTools } from '$lib/apis/tools'; import ToolkitEditor from '$lib/components/workspace/Tools/ToolkitEditor.svelte'; -@@ -50,7 +51,7 @@ +@@ -51,7 +52,7 @@ toast.success($i18n.t('Tool created successfully')); tools.set(await getTools(localStorage.token)); @@ -4409,7 +6387,7 @@ index 8bcc66ce0..b58effed7 100644 }; diff --git a/src/routes/(app)/workspace/tools/edit/+page.svelte b/src/routes/(app)/workspace/tools/edit/+page.svelte -index 18c6cd466..479dfb96f 100644 +index 3b0603fa9457d135438928e01ebf17b1947e6c83..ac96b42947062329f80331aa7f8961481e5ea299 100644 --- a/src/routes/(app)/workspace/tools/edit/+page.svelte +++ b/src/routes/(app)/workspace/tools/edit/+page.svelte @@ -1,4 +1,5 @@ @@ -4418,16 +6396,16 @@ index 18c6cd466..479dfb96f 100644 import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { getToolById, getTools, updateToolById } from '$lib/apis/tools'; -@@ -50,7 +51,7 @@ +@@ -51,7 +52,7 @@ toast.success($i18n.t('Tool updated successfully')); tools.set(await getTools(localStorage.token)); - // await goto('/workspace/tools'); + // await goto(`${base}/workspace/tools`); } + return res; }; - -@@ -61,13 +62,13 @@ +@@ -63,13 +64,13 @@ if (id) { const res = await getToolById(localStorage.token, id).catch((error) => { toast.error(`${error}`); @@ -4444,10 +6422,10 @@ index 18c6cd466..479dfb96f 100644 } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte -index 69bc84986..8d10fa7f8 100644 +index adf8c1763773ee117e73acd09cfebe22fe8e3a22..51c292b2eaef1acb29495a4991caf42161a77715 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte -@@ -55,6 +55,7 @@ +@@ -57,6 +57,7 @@ import { getSessionUser, updateUserTimezone, userSignOut } from '$lib/apis/auths'; import { getAllTags } from '$lib/apis/chats'; import { chatCompletion } from '$lib/apis/openai'; @@ -4455,7 +6433,7 @@ index 69bc84986..8d10fa7f8 100644 import { isTemporaryChatId } from '$lib/utils/chatId'; import { addOpenAIConnection, -@@ -164,12 +165,18 @@ +@@ -176,12 +177,18 @@ }; const setupSocket = async (enableWebsocket) => { @@ -4476,7 +6454,7 @@ index 69bc84986..8d10fa7f8 100644 transports: enableWebsocket ? ['websocket'] : ['polling', 'websocket'], auth: { token: localStorage.token } }); -@@ -608,7 +615,7 @@ +@@ -630,7 +637,7 @@ toast.custom(NotificationToast, { componentProps: { onClick: () => { @@ -4485,7 +6463,7 @@ index 69bc84986..8d10fa7f8 100644 }, title: data.title, content: timeStr -@@ -748,7 +755,7 @@ +@@ -786,7 +793,7 @@ ) { playingNotificationSound.set(true); @@ -4494,7 +6472,7 @@ index 69bc84986..8d10fa7f8 100644 audio.play().finally(() => { // Ensure the global state is reset after the sound finishes playingNotificationSound.set(false); -@@ -770,7 +777,7 @@ +@@ -808,7 +815,7 @@ toast.custom(NotificationToast, { componentProps: { onClick: () => { @@ -4503,7 +6481,7 @@ index 69bc84986..8d10fa7f8 100644 }, content: contentPreview, title: displayTitle -@@ -881,7 +888,7 @@ +@@ -923,7 +930,7 @@ componentProps: { onClick: () => { goto( @@ -4511,8 +6489,8 @@ index 69bc84986..8d10fa7f8 100644 + `${base}/channels/${event.channel_id}${data?.parent_id ? `?thread=${data.parent_id}` : ''}` ); }, - content: data?.content, -@@ -988,12 +995,12 @@ + content, +@@ -1030,12 +1037,12 @@ } if (event.type === 'query' && (event.data?.query || event.data?.files?.length)) { desktopEvent.set(event); @@ -4527,7 +6505,7 @@ index 69bc84986..8d10fa7f8 100644 return; } if (event.type === 'theme:update' && event.data?.theme) { -@@ -1243,7 +1250,7 @@ +@@ -1299,7 +1306,7 @@ if (error?.authRedirect) { // Forward-auth proxy is redirecting to an external login page. // Full-page navigation lets the browser follow the redirect natively. @@ -4536,7 +6514,7 @@ index 69bc84986..8d10fa7f8 100644 return; } console.error('Error loading backend config:', error); -@@ -1314,7 +1321,7 @@ +@@ -1369,7 +1376,7 @@ } } else { // Redirect to /error when Backend Not Detected @@ -4545,7 +6523,7 @@ index 69bc84986..8d10fa7f8 100644 } await tick(); -@@ -1335,7 +1342,7 @@ +@@ -1390,7 +1397,7 @@ document.getElementById('splash-screen')?.remove(); @@ -4554,7 +6532,7 @@ index 69bc84986..8d10fa7f8 100644 const playAudio = () => { audio.play(); document.removeEventListener('click', playAudio); -@@ -1396,7 +1403,7 @@ +@@ -1453,7 +1460,7 @@ rel="search" type="application/opensearchdescription+xml" title={$WEBUI_NAME} @@ -4564,7 +6542,7 @@ index 69bc84986..8d10fa7f8 100644 /> diff --git a/src/routes/error/+page.svelte b/src/routes/error/+page.svelte -index a4bb08ed7..0a693a869 100644 +index a4bb08ed7e0b2aa5f77d5c1ddee7437bf1546d2c..0a693a869df6762670c29b64f1e7b7ef93dfa51b 100644 --- a/src/routes/error/+page.svelte +++ b/src/routes/error/+page.svelte @@ -1,5 +1,6 @@ @@ -4593,7 +6571,7 @@ index a4bb08ed7..0a693a869 100644 > {$i18n.t('Check Again')} diff --git a/src/routes/s/[id]/+page.svelte b/src/routes/s/[id]/+page.svelte -index 4357e9c8f..243a2bd26 100644 +index 72d67183e46a14751f92174d8f8f9374d281cf5d..0510d1586c10f17b9198db0ac44461d81f4d486d 100644 --- a/src/routes/s/[id]/+page.svelte +++ b/src/routes/s/[id]/+page.svelte @@ -1,4 +1,5 @@ @@ -4602,35 +6580,17 @@ index 4357e9c8f..243a2bd26 100644 import { onMount, tick, getContext } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; -@@ -53,7 +54,7 @@ +@@ -55,7 +56,7 @@ await tick(); - loaded = true; - } else { + messagesComponent?.scrollToBottom(); + } else if (localStorage.token) { - await goto('/'); -+ await goto(`${base}/`); ++ await goto(`\${base}/`); + } else { + await goto(`/auth?redirect=${encodeURIComponent($page.url.pathname)}`); } - })(); - } -@@ -103,7 +104,7 @@ - ); - await chatId.set(shareId); - chat = await getChatByShareId(token, shareId).catch(async (error) => { -- await goto('/'); -+ await goto(`${base}/`); - return null; - }); - -@@ -159,7 +160,7 @@ - }); - - if (res) { -- goto(`/c/${res.id}`); -+ goto(`${base}/c/${res.id}`); - } - }; - diff --git a/src/routes/watch/+page.svelte b/src/routes/watch/+page.svelte -index 78eab5be3..b727a8c1a 100644 +index 78eab5be3196feb63b1e44a51c8ade7710ffa510..b727a8c1acea5ec0c5e8434b1cd2954d673d7e12 100644 --- a/src/routes/watch/+page.svelte +++ b/src/routes/watch/+page.svelte @@ -1,4 +1,5 @@ @@ -4653,10 +6613,10 @@ index 78eab5be3..b727a8c1a 100644 }); diff --git a/svelte.config.js b/svelte.config.js -index ffb774e72..4a4b3570a 100644 +index 80e37b4f59de094dbae5ca155c2622cecf078b45..5a1d4cccca45de2518b480f2eeef67d9c4363adc 100644 --- a/svelte.config.js +++ b/svelte.config.js -@@ -3,6 +3,11 @@ import * as child_process from 'node:child_process'; +@@ -3,6 +3,11 @@ import { resolveBuildInfo } from './scripts/build-info.js'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import fs from 'node:fs'; @@ -4686,12 +6646,12 @@ index ffb774e72..4a4b3570a 100644 version: { name: (() => { diff --git a/vite.config.ts b/vite.config.ts -index 631ce5972..aa6d25889 100644 +index 3780dbe6a9b0561b3e8b8a1bf30ce70c0183d9f6..6fe77cf21da6ecf8acbc7d3d46944f27c35f6d8d 100644 --- a/vite.config.ts +++ b/vite.config.ts -@@ -5,6 +5,11 @@ import { viteStaticCopy } from 'vite-plugin-static-copy'; - +@@ -7,6 +7,11 @@ import { viteStaticCopy } from 'vite-plugin-static-copy'; const backendTarget = process.env.WEBUI_BACKEND_URL || 'http://localhost:8080'; + const buildInfo = resolveBuildInfo(); +// Optional URL subpath prefix for reverse-proxy deployments (e.g. "/openwebui"). +// Must start with a leading slash and must not end with a trailing slash. @@ -4701,9 +6661,9 @@ index 631ce5972..aa6d25889 100644 export default defineConfig({ resolve: { conditions: ['onnxruntime-web-use-extern-wasm'] -@@ -25,6 +30,7 @@ export default defineConfig({ - APP_VERSION: JSON.stringify(process.env.npm_package_version), - APP_BUILD_HASH: JSON.stringify(process.env.APP_BUILD_HASH || 'dev-build') +@@ -28,6 +33,7 @@ export default defineConfig({ + APP_BUILD_HASH: JSON.stringify(buildInfo.hash), + APP_BUILD_CHANNEL: JSON.stringify(buildInfo.channel) }, + base: WEBUI_BASE_PATH || '/', build: { diff --git a/docker-compose.yml b/docker-compose.yml index c241c8a7..3786aaa7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -187,7 +187,7 @@ services: # forward /chat/* WITHOUT stripping the prefix (see the Caddyfile header). open-webui: profiles: ["chat"] - image: ${OPEN_WEBUI_IMAGE:-ghcr.io/sushantgautam/open-webui:v0.11.4.3-subpath} + image: ${OPEN_WEBUI_IMAGE:-ghcr.io/sushantgautam/open-webui:v0.12.0-subpath} environment: WEBUI_SUBPATH: /chat WEBUI_AUTH_TRUSTED_EMAIL_HEADER: X-Studio-Email From c378c7c108216338eff26d31be259071c360e7b0 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Wed, 14 Oct 2026 16:09:36 +0200 Subject: [PATCH 2/2] fix(ports): match Open WebUI owner by package name, not checkout path _classify_command substring-matched the whole command line against the open-webui and open_webui spellings. A checkout whose path contains open-webui (e.g. this worktree) made every listener process classify as Studio's own Open WebUI - a force-kill hazard and the cause of 3 test_ports failures. Match the underscore spelling only: that is what a running instance carries (uvicorn open_webui.main:app), so real detection is unchanged while the path-based false positive is removed. Mirrors the existing note for the Studio marker. Test now asserts the underscore form a real process uses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- simpleaudit_studio/ports.py | 7 ++++++- simpleaudit_studio/tests/test_ports.py | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/simpleaudit_studio/ports.py b/simpleaudit_studio/ports.py index c06b8853..67f2270d 100644 --- a/simpleaudit_studio/ports.py +++ b/simpleaudit_studio/ports.py @@ -35,7 +35,12 @@ # count. _STUDIO_MARKERS = ("simpleaudit_studio", "manage.py runserver") _STUDIO_SCRIPTS = ("/spin", "/simpleaudit-studio") -_OPEN_WEBUI_MARKERS = ("open-webui", "open_webui") +# Underscore spelling only: that is what appears in a running Open WebUI's +# command line (``uvicorn open_webui.main:app``). The hyphenated project name +# also appears in checkout paths (e.g. ``.../open-webui/...``), where it must +# not count — matching it on the whole command line would let an unrelated +# process get classified as ours and force-killed. +_OPEN_WEBUI_MARKERS = ("open_webui",) _HATCHET_MARKER = "hatchet-embedded-sidecar" #: Occupants the CLI may stop on the user's behalf. diff --git a/simpleaudit_studio/tests/test_ports.py b/simpleaudit_studio/tests/test_ports.py index 96d9b154..4a0440ce 100644 --- a/simpleaudit_studio/tests/test_ports.py +++ b/simpleaudit_studio/tests/test_ports.py @@ -91,8 +91,10 @@ def test_a_studio_process_is_classified(self): proc.wait() def test_an_open_webui_process_is_classified(self): + # The real listener's command line carries the underscore package name + # (``uvicorn open_webui.main:app``), so that is the marker we assert on. port = _free_port() - proc = _listener_proc(port, marker="open-webui") + proc = _listener_proc(port, marker="open_webui.main:app") try: owner = _wait_port_owned(port) self.assertEqual(owner.kind, "open_webui")