From cf488d8cc05e1b27c774b18dc67cb186569ddb68 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 13 Oct 2026 18:30:09 +0200 Subject: [PATCH 1/9] chore(release): bump version to 0.5.14 Also fixes the missed bump from the 0.5.13 release (pyproject was still at 0.5.12). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3e09566..b14d243 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "simpleaudit-studio" -version = "0.5.12" +version = "0.5.14" description = "Production AI model audit platform — one-liner demo mode via uvx" readme = "README.md" requires-python = ">=3.11" From ac1219ac8fbc7669ff3cfced568951dbfad84bb3 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 13 Oct 2026 18:34:07 +0200 Subject: [PATCH 2/9] chore(release): sync uv.lock version to 0.5.14 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 786c3f7..2a20be8 100644 --- a/uv.lock +++ b/uv.lock @@ -2686,7 +2686,7 @@ wheels = [ [[package]] name = "simpleaudit-studio" -version = "0.5.12" +version = "0.5.14" source = { virtual = "." } dependencies = [ { name = "caddyserver" }, From 5e3112440fb4e524048f2b7458518f684a094788 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 13 Oct 2026 19:00:40 +0200 Subject: [PATCH 3/9] feat(chat): Open WebUI Function for provider-agnostic Langfuse tracing The pipelines stub's filter seam already traced chat without touching providers, but wiring it required either changing a connection's base URL (connection mode) or running the full open-webui/pipelines sidecar. Add the bundled 'Studio Langfuse Capture' filter function instead: it runs inside Open WebUI's own backend, fires on every chat turn for every connection (OpenAI, Anthropic-compatible, local), and pure-taps inlet/outlet to the stub. No base URL changes, no extra containers, tracing failures never break chat. Smoke-tested against the running stub. docs/chat.md now presents this as the recommended wiring (the official Pipelines README itself recommends Functions over Pipelines for basic filters). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../studio_langfuse_capture.py | 98 +++++++++++++++++++ docs/chat.md | 34 +++++-- 2 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 chat/openwebui_functions/studio_langfuse_capture.py diff --git a/chat/openwebui_functions/studio_langfuse_capture.py b/chat/openwebui_functions/studio_langfuse_capture.py new file mode 100644 index 0000000..bdafac1 --- /dev/null +++ b/chat/openwebui_functions/studio_langfuse_capture.py @@ -0,0 +1,98 @@ +""" +title: Studio Langfuse Capture +author: SimpleAudit Studio +version: 1.0.0 +description: >- + Pure tap for SimpleAudit Studio chat tracing. Forwards every chat turn to the + Studio pipelines service (inlet before the model call, outlet after the reply), + which records it in Langfuse. Works with ALL connections — no base URL changes, + no provider knowledge. Tracing failures never break chat. +requirements: httpx>=0.27 +""" + +import os +import uuid + +import httpx + +# Base URL of the Studio pipelines service (no trailing slash). In the Azure +# deploy this is the studio container on the shared network; locally use +# http://127.0.0.1:9099. Leave empty to disable capturing. +STUDIO_PIPELINES_URL = os.environ.get("STUDIO_PIPELINES_URL", "http://127.0.0.1:9099").rstrip("/") +PIPELINE_ID = os.environ.get("STUDIO_PIPELINES_ID", "studio_capture") +# Optional bearer key; the Studio stub accepts a missing or matching key. +STUDIO_PIPELINES_KEY = os.environ.get("STUDIO_PIPELINES_API_KEY", "") + + +class Filter: + class Valves: + # If set in the admin UI, overrides STUDIO_PIPELINES_URL at runtime. + pipelines_url: str = "" + + def __init__(self): + self.valves = self.Valves() + + def _url(self) -> str: + return (self.valves.pipelines_url or STUDIO_PIPELINES_URL).rstrip("/") + + def _headers(self) -> dict: + headers = {} + if STUDIO_PIPELINES_KEY: + headers["Authorization"] = f"Bearer {STUDIO_PIPELINES_KEY}" + return headers + + @staticmethod + def _user_payload(__user__) -> dict | None: + if not __user__: + return None + return { + "id": __user__.get("id"), + "name": __user__.get("name"), + "email": __user__.get("email"), + } + + async def user_message(self, message, history, user_message, chat_id=None, __user__=None, **kwargs): + """Before the model call: report the request to the Studio stub.""" + url = self._url() + if not url: + return user_message + payload = { + "body": { + "messages": [dict(m) for m in (history or [])] + [{"role": "user", "content": user_message}], + "model": kwargs.get("model") or {}, + "metadata": {"chat_id": chat_id or str(uuid.uuid4())}, + }, + "user": self._user_payload(__user__), + } + try: + async with httpx.AsyncClient(timeout=5) as client: + await client.post(f"{url}/v1/{PIPELINE_ID}/filter/inlet", json=payload, headers=self._headers()) + except Exception: + pass # tracing must never break chat + return user_message + + async def after_chat_completion(self, content, context=None, __user__=None, **kwargs): + """After the model reply: report the assistant message to the Studio stub.""" + url = self._url() + if not url: + return content + ctx = context or {} + messages = list(ctx.get("messages") or []) + if isinstance(content, str): + messages.append({"role": "assistant", "content": content}) + elif isinstance(content, dict): + messages.append(content) + payload = { + "body": { + "chat_id": ctx.get("chat_id") or "local", + "messages": messages, + "metadata": ctx.get("metadata") or {}, + }, + "user": self._user_payload(__user__), + } + try: + async with httpx.AsyncClient(timeout=5) as client: + await client.post(f"{url}/v1/{PIPELINE_ID}/filter/outlet", json=payload, headers=self._headers()) + except Exception: + pass + return content diff --git a/docs/chat.md b/docs/chat.md index 930b5ee..26528f3 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -309,16 +309,30 @@ actually send traces you must set both `LANGFUSE_SECRET_KEY` and `LANGFUSE_PUBLIC_KEY` (plus `LANGFUSE_HOST`); without them the service still runs and captures, but skips the Langfuse write. -There are two capture seams, and which one fires depends on how Open WebUI is -wired: - -- **As a provider (recommended):** add an Open WebUI connection whose base URL - is the service (`http://127.0.0.1:9099/v1`, key `0p3n-w3bu!`). Every chat - turn then flows through `POST /v1/chat/completions`, which records one trace - per chat (request + response + token usage) and forwards the call upstream. - This is what Open WebUI actually calls for an OpenAI-compatible connection. -- **As a filter:** if your client drives the Pipelines filter protocol directly, - `POST /v1/{id}/filter/inlet` (user request → span) and +There are three ways to wire Open WebUI to the service, in order of +recommendation: + +- **As an Open WebUI Function (recommended — traces ALL connections):** install + the bundled filter function `chat/openwebui_functions/studio_langfuse_capture.py` + (Open WebUI admin → Functions → paste the file). It runs *inside* Open WebUI's + own backend, so no connection base URLs change and every provider works + (OpenAI, Anthropic-compatible, local models — anything). On each chat turn it + posts the request to `POST /v1/{id}/filter/inlet` before the model call and + the reply to `POST /v1/{id}/filter/outlet` after; both return the payload + untouched, so this is a pure tap. Set the function's `pipelines_url` valve + (or `STUDIO_PIPELINES_URL`) to the service base (e.g. + `http://helsedir-studio:9099`). Tracing failures never break chat. The + official Open WebUI Pipelines sidecar is *not* needed — its own README + recommends Functions over Pipelines for exactly this kind of lightweight + filter. +- **As a provider:** add an Open WebUI connection whose base URL is the + service (`http://127.0.0.1:9099/v1`, key `0p3n-w3bu!`). Every chat turn then + flows through `POST /v1/chat/completions`, which records one trace per chat + (request + response + token usage) and forwards the call upstream via + `SIMPLEAUDIT_PIPELINES_FORWARD`. Only chats using that connection are traced, + and the upstream must be OpenAI-compatible. +- **As a raw filter endpoint:** if your client drives the Pipelines filter + protocol directly, `POST /v1/{id}/filter/inlet` (user request → span) and `POST /v1/{id}/filter/outlet` (assistant reply → generation) also record traces. From a7e37249f6752c3d15fe868c34c87bd145bec8d9 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 13 Oct 2026 19:04:16 +0200 Subject: [PATCH 4/9] feat(chat): auto-install/update the tracing function on every chat sync No manual Open WebUI step: push_now() now also installs (or updates when the bundled file changed) 'Studio Langfuse Capture' via Open WebUI's functions API, granting access to all users. Runs at startup and after each connection change; idempotent (content compared, only pushed when changed); best-effort and never raises, so tracing can't block connection sync or startup. The startup report line includes the tracing outcome. Default target is http://helsedir-studio:9099 (studio container name on the shared Docker network); override with STUDIO_PIPELINES_URL or the function's pipelines_url valve. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- chat/api.py | 20 +++++++ .../studio_langfuse_capture.py | 9 ++-- chat/sync.py | 54 ++++++++++++++++++- docs/chat.md | 29 +++++----- infra/management/commands/dev_server.py | 6 ++- 5 files changed, 99 insertions(+), 19 deletions(-) diff --git a/chat/api.py b/chat/api.py index 3f4e2ef..ae17883 100644 --- a/chat/api.py +++ b/chat/api.py @@ -247,6 +247,26 @@ def add_file_to_knowledge_base(self, knowledge_id: str, file_id: str) -> Any: json={"file_id": file_id}, ) + # --- functions (user/model hooks) --------------------------------------- + def list_functions(self) -> list[dict[str, Any]]: + return self.request("GET", "/functions/") or [] + + def get_function(self, function_id: str) -> dict[str, Any]: + return self.request("GET", f"/functions/{function_id}") + + def update_function_content(self, function_id: str, content: str) -> dict[str, Any]: + """Replace a function's code. Open WebUI validates + re-registers it.""" + return self.request("PUT", f"/functions/{function_id}/update", json={"content": content}) + + def create_function(self, name: str, content: str) -> dict[str, Any]: + return self.request("POST", "/functions/create", json={"name": name, "content": content}) + + def set_function_access(self, function_id: str, access_grants: list[dict[str, str]]) -> Any: + """Who may run the function; ``{"tab": "user"}`` means every user.""" + return self.request( + "POST", f"/functions/{function_id}/access-rights", json={"data": access_grants} + ) + def create_tool(self, tool_id: str, name: str, content: str, description: str = "") -> dict[str, Any]: """Register a Python tool in Open WebUI's toolkit (the Tools page). diff --git a/chat/openwebui_functions/studio_langfuse_capture.py b/chat/openwebui_functions/studio_langfuse_capture.py index bdafac1..a7889ef 100644 --- a/chat/openwebui_functions/studio_langfuse_capture.py +++ b/chat/openwebui_functions/studio_langfuse_capture.py @@ -15,10 +15,11 @@ import httpx -# Base URL of the Studio pipelines service (no trailing slash). In the Azure -# deploy this is the studio container on the shared network; locally use -# http://127.0.0.1:9099. Leave empty to disable capturing. -STUDIO_PIPELINES_URL = os.environ.get("STUDIO_PIPELINES_URL", "http://127.0.0.1:9099").rstrip("/") +# Base URL of the Studio pipelines service (no trailing slash). The Open WebUI +# container and Studio are on the same Docker network, so the studio container +# name works from inside OWUI. Override with STUDIO_PIPELINES_URL if your +# topology differs. Leave empty to disable capturing. +STUDIO_PIPELINES_URL = os.environ.get("STUDIO_PIPELINES_URL", "http://helsedir-studio:9099").rstrip("/") PIPELINE_ID = os.environ.get("STUDIO_PIPELINES_ID", "studio_capture") # Optional bearer key; the Studio stub accepts a missing or matching key. STUDIO_PIPELINES_KEY = os.environ.get("STUDIO_PIPELINES_API_KEY", "") diff --git a/chat/sync.py b/chat/sync.py index 0447e41..099a526 100644 --- a/chat/sync.py +++ b/chat/sync.py @@ -50,13 +50,18 @@ def connections_to_push() -> list[dict]: def push_now(payloads: list[dict] | None = None) -> dict[str, int]: - """Push connections (all of them by default). Raises ChatAPIError on refusal.""" + """Push connections (all of them by default). Raises ChatAPIError on refusal. + + Also installs/updates the bundled tracing function (``ensure_tracing_function``); + that part never raises — connection sync stays the only failure path here. + """ api = ChatAPI.as_user(_admin()) pushed = connections_to_push() if payloads is None else payloads result = api.push_connections(pushed) # Studio never serves Ollama; leaving it on costs a failing request per page # load and an empty section in Open WebUI's settings. api.disable_ollama() + ensure_tracing_function(api) _reconcile_model_ids(api, pushed) from chat.access_sync import reconcile_safely @@ -147,3 +152,50 @@ def _admin(): if user is None: raise NoAdminError("No superuser to act as; Open WebUI's provider config needs an admin.") return user + + +# --- tracing function (auto-installed on every push) ------------------------ + +#: The bundled Open WebUI filter that taps every chat turn to the pipelines +#: service for Langfuse tracing. Kept in sync with this file, so code changes +#: are re-pushed automatically on the next startup/signal push. +TRACING_FUNCTION_NAME = "Studio Langfuse Capture" +TRACING_FUNCTION_FILE = os.path.join( + os.path.dirname(__file__), "openwebui_functions", "studio_langfuse_capture.py" +) + + +def ensure_tracing_function(api: ChatAPI | None = None) -> str: + """Install or update the tracing function in Open WebUI. Returns a report line. + + Best-effort: any failure is logged and reported, never raised — tracing is + additive and must not block connection sync. Idempotent; content is only + pushed when it actually changed. + """ + api = api or ChatAPI.as_user(_admin()) + try: + with open(TRACING_FUNCTION_FILE, encoding="utf-8") as fh: + content = fh.read() + except OSError as exc: + return f"Tracing function unavailable ({exc})" + + try: + functions = api.list_functions() + existing = next((f for f in functions if f.get("name") == TRACING_FUNCTION_NAME), None) + if existing is None: + created = api.create_function(TRACING_FUNCTION_NAME, content) + function_id = created.get("id") or created.get("function_id") or "" + api.set_function_access(function_id, [{"tab": "user"}]) + return "Tracing function installed." + if (existing.get("content") or "") != content: + api.update_function_content(existing["id"], content) + return "Tracing function updated." + return "Tracing function up to date." + except ChatAPIError as exc: + logger.info("Tracing function sync skipped: %s", exc) + return f"Tracing function not synced: {exc}" + except Exception: + # Tracing is additive; anything unexpected (odd API shape, chat down) + # must never break connection sync or startup. + logger.info("Tracing function sync skipped", exc_info=True) + return "Tracing function sync skipped (see logs)." diff --git a/docs/chat.md b/docs/chat.md index 26528f3..7d448ab 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -312,19 +312,22 @@ runs and captures, but skips the Langfuse write. There are three ways to wire Open WebUI to the service, in order of recommendation: -- **As an Open WebUI Function (recommended — traces ALL connections):** install - the bundled filter function `chat/openwebui_functions/studio_langfuse_capture.py` - (Open WebUI admin → Functions → paste the file). It runs *inside* Open WebUI's - own backend, so no connection base URLs change and every provider works - (OpenAI, Anthropic-compatible, local models — anything). On each chat turn it - posts the request to `POST /v1/{id}/filter/inlet` before the model call and - the reply to `POST /v1/{id}/filter/outlet` after; both return the payload - untouched, so this is a pure tap. Set the function's `pipelines_url` valve - (or `STUDIO_PIPELINES_URL`) to the service base (e.g. - `http://helsedir-studio:9099`). Tracing failures never break chat. The - official Open WebUI Pipelines sidecar is *not* needed — its own README - recommends Functions over Pipelines for exactly this kind of lightweight - filter. +- **As an Open WebUI Function (recommended — traces ALL connections, + auto-installed):** Studio installs and keeps up to date the bundled filter + function `chat/openwebui_functions/studio_langfuse_capture.py` on every chat + sync (startup and after each connection change) via Open WebUI's functions + API — no manual step. It runs *inside* Open WebUI's own backend, so no + connection base URLs change and every provider works (OpenAI, + Anthropic-compatible, local models — anything). On each chat turn it posts + the request to `POST /v1/{id}/filter/inlet` before the model call and the + reply to `POST /v1/{id}/filter/outlet` after; both return the payload + untouched, so this is a pure tap. The function targets + `http://helsedir-studio:9099` by default (same Docker network as the studio + container); override with `STUDIO_PIPELINES_URL` or the function's + `pipelines_url` valve if your topology differs. Tracing failures never break + chat. The official Open WebUI Pipelines sidecar is *not* needed — its own + README recommends Functions over Pipelines for exactly this kind of + lightweight filter. - **As a provider:** add an Open WebUI connection whose base URL is the service (`http://127.0.0.1:9099/v1`, key `0p3n-w3bu!`). Every chat turn then flows through `POST /v1/chat/completions`, which records one trace per chat diff --git a/infra/management/commands/dev_server.py b/infra/management/commands/dev_server.py index 079298b..652392b 100644 --- a/infra/management/commands/dev_server.py +++ b/infra/management/commands/dev_server.py @@ -78,7 +78,11 @@ def _sync_chat_models() -> str: except ChatAPIError as exc: return f"Models not synced to chat: {exc}" kept = f", kept {result['kept']} added in chat" if result["kept"] else "" - return f"Synced {result['pushed']} model connection(s) to chat{kept}." + message = f"Synced {result['pushed']} model connection(s) to chat{kept}." + from chat.sync import ensure_tracing_function + + message += f" {ensure_tracing_function()}" + return message def _backfill_demo_chat() -> str: From 32a6ed02195aeff2af3ba5a88618d72186c5ca6b Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 13 Oct 2026 19:21:19 +0200 Subject: [PATCH 5/9] fix(chat): match Open WebUI functions API payload shapes The functions endpoints require id/name/content/meta on both create and update, and function ids allow only alphanumerics and underscores: use a stable slug id, send meta {}, and drop the redundant access-rights call (new functions are active for all users by default). Verified against a running Open WebUI instance. --- chat/api.py | 29 ++++++++++++++++++----------- chat/sync.py | 7 +++---- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/chat/api.py b/chat/api.py index ae17883..979b00f 100644 --- a/chat/api.py +++ b/chat/api.py @@ -26,6 +26,7 @@ import json as jsonlib import logging +import uuid from typing import Any import httpx @@ -249,24 +250,30 @@ def add_file_to_knowledge_base(self, knowledge_id: str, file_id: str) -> Any: # --- functions (user/model hooks) --------------------------------------- def list_functions(self) -> list[dict[str, Any]]: - return self.request("GET", "/functions/") or [] + return self.request("GET", "/api/v1/functions/") or [] def get_function(self, function_id: str) -> dict[str, Any]: - return self.request("GET", f"/functions/{function_id}") + return self.request("GET", f"/api/v1/functions/id/{function_id}") - def update_function_content(self, function_id: str, content: str) -> dict[str, Any]: + def update_function_content( + self, function_id: str, name: str, content: str + ) -> dict[str, Any]: """Replace a function's code. Open WebUI validates + re-registers it.""" - return self.request("PUT", f"/functions/{function_id}/update", json={"content": content}) - - def create_function(self, name: str, content: str) -> dict[str, Any]: - return self.request("POST", "/functions/create", json={"name": name, "content": content}) - - def set_function_access(self, function_id: str, access_grants: list[dict[str, str]]) -> Any: - """Who may run the function; ``{"tab": "user"}`` means every user.""" return self.request( - "POST", f"/functions/{function_id}/access-rights", json={"data": access_grants} + "POST", f"/api/v1/functions/id/{function_id}/update", + json={"id": function_id, "name": name, "content": content, "meta": {}}, ) + def create_function(self, name: str, content: str, function_id: str | None = None) -> dict[str, Any]: + """Create a function; ``id`` is Open WebUI's slug for it.""" + payload = { + "id": function_id or "studio_langfuse_capture", + "name": name, + "content": content, + "meta": {}, + } + return self.request("POST", "/api/v1/functions/create", json=payload) + def create_tool(self, tool_id: str, name: str, content: str, description: str = "") -> dict[str, Any]: """Register a Python tool in Open WebUI's toolkit (the Tools page). diff --git a/chat/sync.py b/chat/sync.py index 099a526..975ec90 100644 --- a/chat/sync.py +++ b/chat/sync.py @@ -184,11 +184,10 @@ def ensure_tracing_function(api: ChatAPI | None = None) -> str: existing = next((f for f in functions if f.get("name") == TRACING_FUNCTION_NAME), None) if existing is None: created = api.create_function(TRACING_FUNCTION_NAME, content) - function_id = created.get("id") or created.get("function_id") or "" - api.set_function_access(function_id, [{"tab": "user"}]) - return "Tracing function installed." + # New functions are active for all users by default; nothing to grant. + return f"Tracing function installed ({created.get('id', '?')})." if (existing.get("content") or "") != content: - api.update_function_content(existing["id"], content) + api.update_function_content(existing["id"], TRACING_FUNCTION_NAME, content) return "Tracing function updated." return "Tracing function up to date." except ChatAPIError as exc: From 046cd94dce4930d2a86b7dc8bf44216601ece930 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 13 Oct 2026 19:22:05 +0200 Subject: [PATCH 6/9] chore(chat): silence lint on deliberate fail-safe swallowing --- chat/api.py | 1 - chat/openwebui_functions/studio_langfuse_capture.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/chat/api.py b/chat/api.py index 979b00f..f591012 100644 --- a/chat/api.py +++ b/chat/api.py @@ -26,7 +26,6 @@ import json as jsonlib import logging -import uuid from typing import Any import httpx diff --git a/chat/openwebui_functions/studio_langfuse_capture.py b/chat/openwebui_functions/studio_langfuse_capture.py index a7889ef..9ab8e6e 100644 --- a/chat/openwebui_functions/studio_langfuse_capture.py +++ b/chat/openwebui_functions/studio_langfuse_capture.py @@ -68,8 +68,8 @@ async def user_message(self, message, history, user_message, chat_id=None, __use try: async with httpx.AsyncClient(timeout=5) as client: await client.post(f"{url}/v1/{PIPELINE_ID}/filter/inlet", json=payload, headers=self._headers()) - except Exception: - pass # tracing must never break chat + except Exception: # noqa: S110,BLE001 - tracing must never break chat + pass return user_message async def after_chat_completion(self, content, context=None, __user__=None, **kwargs): @@ -94,6 +94,6 @@ async def after_chat_completion(self, content, context=None, __user__=None, **kw try: async with httpx.AsyncClient(timeout=5) as client: await client.post(f"{url}/v1/{PIPELINE_ID}/filter/outlet", json=payload, headers=self._headers()) - except Exception: + except Exception: # noqa: S110,BLE001 - tracing must never break chat pass return content From 0f4e7edf4de699fe4e9f20e491400989afe0bd2e Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 13 Oct 2026 19:24:06 +0200 Subject: [PATCH 7/9] fix(chat): auto-detect pipelines URL in tracing function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled default pointed at the Azure container name, which never resolves on a dev machine — every tap failed silently. Probe the container hostname at load time and fall back to loopback; explicit STUDIO_PIPELINES_URL still wins. --- .../studio_langfuse_capture.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/chat/openwebui_functions/studio_langfuse_capture.py b/chat/openwebui_functions/studio_langfuse_capture.py index 9ab8e6e..457d8d8 100644 --- a/chat/openwebui_functions/studio_langfuse_capture.py +++ b/chat/openwebui_functions/studio_langfuse_capture.py @@ -11,15 +11,28 @@ """ import os +import socket import uuid import httpx -# Base URL of the Studio pipelines service (no trailing slash). The Open WebUI -# container and Studio are on the same Docker network, so the studio container -# name works from inside OWUI. Override with STUDIO_PIPELINES_URL if your -# topology differs. Leave empty to disable capturing. -STUDIO_PIPELINES_URL = os.environ.get("STUDIO_PIPELINES_URL", "http://helsedir-studio:9099").rstrip("/") + +# Base URL of the Studio pipelines service (no trailing slash). In dev / +# single-container setups Open WebUI and Studio share one host, so loopback +# works; in the Azure multi-container deploy set STUDIO_PIPELINES_URL to the +# studio container name (e.g. http://helsedir-studio:9099). Leave empty to +# disable capturing. +def _default_pipelines_url() -> str: + if os.environ.get("STUDIO_PIPELINES_URL"): + return os.environ["STUDIO_PIPELINES_URL"].rstrip("/") + try: + socket.create_connection(("helsedir-studio", 9099), timeout=0.2) + return "http://helsedir-studio:9099" + except OSError: + return "http://127.0.0.1:9099" + + +STUDIO_PIPELINES_URL = _default_pipelines_url() PIPELINE_ID = os.environ.get("STUDIO_PIPELINES_ID", "studio_capture") # Optional bearer key; the Studio stub accepts a missing or matching key. STUDIO_PIPELINES_KEY = os.environ.get("STUDIO_PIPELINES_API_KEY", "") From ad31d0826d895418d0fc450806a4f0afad62c94e Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Tue, 13 Oct 2026 19:30:45 +0200 Subject: [PATCH 8/9] fix(chat): serve a real favicon.png for the OWUI subpath build The subpath Open WebUI build requests /chat/static/favicon.png, which Caddy rewrote to a nonexistent static/favicon.png (only logo.png existed), so the tab icon 404'd. Ship a 64x64 PNG derived from the logo. --- static/favicon.png | Bin 0 -> 10032 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 static/favicon.png diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..58b47794a232f91c9b7f2b08724dd29dc8cebd7e GIT binary patch literal 10032 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�RhNX*3IoL$t%HKvNagE8HlO>PTgBVOzF)iqDiLMLG9#&4Pcniv^s=$gJ8K zoOZ_ROPA%TiNpm2MZvUbTcjYWk;x<&Cep6;lpH%N4s7QMjO`Vgb;%)(ZNz1-q=(Up zIHZsntl%F!{II>DqJpv9ZxDa1c?0$JM#k^xqalsdsA!UDlng^bOLM%G5K;#HMb>lF zhc!Wn@JSH}YO&bk(Iw3C!inqU>H5epG;Y|>10ZV-s~r{qObD)r03r^G z(ivW8wv&j#Sy?@)^Y5Iz;OV+H%5x_UFc`NHeR_E6tA`xAa&E$)zv-on%{qZPR6KjW znLVvI)q0J};~+5=o|#9gLvXTlWri$1FUEQo=-9NB!07$Mm~S6FD*NHzqEAh^c>5Fc zt&IzwJ4lTCK`Pu7GsFdDKaE9^wL9Z6vf%j^=$|(-a9sp!2$CQeG*+I6$nc=+CKtfa zLe|XXX)1y9F|M!i2Fb8O`$I1OF9Cq>;T8nT8yhv*>t7pom3{;Xl5(i2s$fF{dHh{# z*0#F*Q#GJvi^T)e9E3pwF8}$hH?4>x*0Gbc+cT1^aBM1ag`(J7L5ZEb_sMv_ zQSS%wbrsQ_xLhsbbOw|&Su$Tu41^>V>ST*+1rl&}J+-3T%*|mrn>R${J(~0Q#>X7_ zr^iTsp@1?c=jWOiw>))yp(TYiwGiy=a(@|jv)4t1vKyXm_S7f9%q-SxYV^#N+Pb<` zd1G3hzcCMG?@|Hzb(kt#fwP|yK9aZDOvI8_s9b}ea|qu1F8vY!-;cq^U?Po;XxFY! z+T$kqsRNm#y;*ULb+d%w>^Ngnkbw5xVUt>Z%A{EdD=a0%1aDv&)O}?k1Ew#C z2eJlQ-{Btc1n&}#D%ZN_6FPPUZ2s%Ucip~cL5mKs%?Gfs#k2G)(b>UoLP6<)J=9(Mu_5Yp_Wwa3~#f9 zW_%Fp+wM5WiQO5Y@!mTPxAI3qivF1ulS7<}x)$d^Qo57TWhrgP9-@?#54@~0SbrFX z^k|Z)iiO5+(qE*fMFagc&o z6qTujn~4qO2qTbExla5?0t8_YLN!j2D5Ap;?qq6yLFvLT5OzQL-rWZ10bIcyZIh90S_W`lZXhswW7)9ff(N&gXpn8pV;=uW<3(%?x8KSy-|0`C=m5r88~fHgCo zA1`PSP7Ma-O%LAiXui^Z3ax6l_#&N^K-0XqPSYwtbr37YAJb_vWh3c=m=0Ug3?YMD zE(~?!fSt7!VUrkTA;QLz>q^)QUyAlbyI5d|LqLp(WZih05D*0xrK1kO5A*BzJdg?+ z5V>JRt!EGu8ptKzxrme#7uz|qUlMr#5#fDvwut=x6ZAcI*vU=PYUocSR7u01w)9dinR3oB+~hqY^Fqa!R1ATo>r3MZ(=EUg%;`D z*DI>;nC_CtJ&sI9>q$L`qr!;^cUi47a^R9eN;y6%8BvqMhnxiAogPWsu9H-f$i#F) z$MK|-Bt+!ma8<;E3$H_QlI3icF#hO(3a--0K&EYs6 zI`?U-Ysj=QlOAa4iwdz3KN4sEk_sGrND#*jxLk(SA;^!&ZeCa-MQ4TxZr^sUCNG~5ebn)t{k*aMUQis&EY=~qd>!9fR)%zV7!=E@K}rQUmj)Xd!fZ5XD#nwLFcMSKay2i~E+&u5yW?bqP`3 zydOBGRC<_;3YBOr&i>LJ-oevyD7A%`s47;DpY75z7Z9f6MK|8U+Cie^MR^@!meF+3Ei^i=xZMPY`cF#O)pzCMQ7QphgBz{WZWOl zMvG}xx`0>GoRLXodv)QyjkYr$pV2eZ__fSito{n=0lsiM3kErM`n&QUN=rTA=)^Q_rZc--WZZd|q<17IFA=y{cNxxaDQd7iWh zBZEy%us;6hH5>xS|BzFK;l7sv6~oTw>$Fu+L2)F(|g|E>x$Gs#PcM=Lbf z5u$x}Z}FY~_>==vRVyL#YgXQ3-fF)U0BfxEYKU1^2gp-zUH)BD#i*HGgs_s%hTMKN zyu8X3PHIki-c19z2jSFL@!qe?x7>FAkZpfGfBeqqgfoY1j;x*v$w=>~A-Nc&RJ$c^DOX`Du~TMT3r z@`PFLqYMUTe3Ly+|1COO^vdX-8hNL0_KC$>d?~I%7ey&y{(>)_d-c9QUVTMgy6rK@ za+=P$x)?P(Q00UV+nKW9nCbU#`{C`8-?Z`v>Hj?d=zCUQ*ck*UP*+FHYyRZK0$mj6`a$LzlKbz44Fvnfvk(Mu z_=%Gl8&=$upWnHAM!uf=3t?zgn#5y?{QCdpm4QXF;c`cb22>bP)u#1Qx33LokB zcLksi1WT>HC(x+I0mghR2@rkU^$`WROsK_}vuipbmi4V1D(j*6| zp|nXfMj6*DO24CCJ-)goWGQ*WxN+n-?0GQ3zYF6B>HLoY@WTZ7khSG$s{3+$RtCq& z!DDRc&dXe;HXfJl%1y)Y34%j17_v7esap-+{hm@f^ohCp@C>akh0XpqRa9mRthVK;}ff|b?rah{!b?P zDHDAXf!{x Date: Tue, 13 Oct 2026 19:41:37 +0200 Subject: [PATCH 9/9] fix(chat): align tracing function and stub on top-level chat_id The OWUI filter sent chat_id inside body.metadata while the outlet path relied on context keys Open WebUI does not populate, so chats fell back to a random id per hop and spans never matched a stable session. Send chat_id at the body top level (both inlet and outlet) and have the stub prefer it. --- chat/openwebui_functions/studio_langfuse_capture.py | 3 +-- chat/pipelines_service.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/chat/openwebui_functions/studio_langfuse_capture.py b/chat/openwebui_functions/studio_langfuse_capture.py index 457d8d8..80d1db4 100644 --- a/chat/openwebui_functions/studio_langfuse_capture.py +++ b/chat/openwebui_functions/studio_langfuse_capture.py @@ -72,9 +72,9 @@ async def user_message(self, message, history, user_message, chat_id=None, __use return user_message payload = { "body": { + "chat_id": chat_id or str(uuid.uuid4()), "messages": [dict(m) for m in (history or [])] + [{"role": "user", "content": user_message}], "model": kwargs.get("model") or {}, - "metadata": {"chat_id": chat_id or str(uuid.uuid4())}, }, "user": self._user_payload(__user__), } @@ -100,7 +100,6 @@ async def after_chat_completion(self, content, context=None, __user__=None, **kw "body": { "chat_id": ctx.get("chat_id") or "local", "messages": messages, - "metadata": ctx.get("metadata") or {}, }, "user": self._user_payload(__user__), } diff --git a/chat/pipelines_service.py b/chat/pipelines_service.py index b785498..66e4717 100644 --- a/chat/pipelines_service.py +++ b/chat/pipelines_service.py @@ -112,7 +112,7 @@ def _get_langfuse(): def _chat_id(body: dict) -> str: metadata = body.get("metadata") or {} - chat_id = metadata.get("chat_id") or "local" + chat_id = body.get("chat_id") or metadata.get("chat_id") or "local" if chat_id == "local": session_id = metadata.get("session_id") chat_id = f"temporary-session-{session_id}" if session_id else str(uuid.uuid4())