traceAI-openwebui 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ *.py[cod]
2
+
3
+ # C extensions
4
+ *.so
5
+
6
+ # IDEs
7
+ .idea/
8
+ .vscode/
9
+
10
+ # Packages
11
+ *.egg
12
+ *.egg-info
13
+ .eggs/
14
+ # build
15
+ parts
16
+ bin
17
+ var
18
+ sdist
19
+ dist
20
+ develop-eggs
21
+ .installed.cfg
22
+ lib
23
+ lib64
24
+
25
+ # Installer logs
26
+ pip-log.txt
27
+
28
+ # Unit test / coverage reports
29
+ .coverage
30
+ .tox
31
+ nosetests.xml
32
+
33
+ # Complexity
34
+ output/*.html
35
+ output/*/index.html
36
+
37
+ # Sphinx
38
+ docs/_build
39
+
40
+ # Cookiecutter
41
+ output/
42
+
43
+ .workon
44
+
45
+ **.wav
46
+ **.joblib
47
+
48
+ **/__pycache__
49
+
50
+ #Excel files
51
+ *.xlsx
52
+ #csv files
53
+ *.csv
54
+ #virtual environments
55
+ **/.venv
56
+ **/env
57
+ **/.env
58
+ *.DS_Store
59
+ *.pdf
60
+ **/node_modules
61
+ **/dist
62
+ *.log
63
+ .DS_Store
64
+ **/package-lock.json
65
+ # Standalone npm example (outside the pnpm workspace): its lockfile pins the tested dependency set.
66
+ !typescript/examples/tanstack-ai/package-lock.json
67
+ **/tsconfig.tsbuildinfo
68
+ **/.fi
69
+ **/.pytest_cache
70
+ planning/
71
+
72
+ # Java build artifacts
73
+ **/target/
74
+ **/.settings/
75
+ **/.project
76
+ **/.classpath
77
+ **/.factorypath
78
+ *.class
79
+ *.jar
80
+ *.war
81
+
82
+ # C# build artifacts
83
+ **/bin/
84
+ **/obj/
85
+
86
+ # Python
87
+ **/uv.lock
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.5
2
+ Name: traceAI-openwebui
3
+ Version: 0.1.0
4
+ Summary: Trace real Open WebUI model calls with a maintained Pipe Function
5
+ Project-URL: Repository, https://github.com/future-agi/traceAI
6
+ Project-URL: Documentation, https://docs.futureagi.com/docs/integrations/no-code/open-webui
7
+ Author-email: Future AGI <no-reply@futureagi.com>
8
+ License-Expression: Apache-2.0
9
+ Requires-Python: <3.13,>=3.11
10
+ Requires-Dist: certifi>=2024.7.4
11
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.27
12
+ Requires-Dist: opentelemetry-sdk<2,>=1.27
13
+ Requires-Dist: packaging<27,>=24
14
+ Requires-Dist: pydantic<3,>=2.9
15
+ Requires-Dist: starlette<1,>=0.40
16
+ Provides-Extra: dev
17
+ Requires-Dist: build<2,>=1.2; extra == 'dev'
18
+ Requires-Dist: mypy<2,>=1.18; extra == 'dev'
19
+ Requires-Dist: pytest-asyncio<1,>=0.24; extra == 'dev'
20
+ Requires-Dist: pytest<9,>=8; extra == 'dev'
21
+ Requires-Dist: ruff<1,>=0.14; extra == 'dev'
22
+ Requires-Dist: types-requests>=2.32; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # traceAI-openwebui
26
+
27
+ Trace each model call made through an Open WebUI Pipe Function in Future AGI, with streaming duration, provider-reported usage, and failure or cancellation status.
28
+
29
+ ## Install
30
+
31
+ Use Python 3.11 or 3.12 and Open WebUI 0.11.4–0.12.x. Configure an OpenAI-compatible **Chat Completions** connection model in Open WebUI first.
32
+
33
+ Native Ollama and **Responses API** connections are not supported. Open WebUI's conversions can hide failures or incomplete responses, so the Pipe rejects these connections before making a model request or recording a span. Responses API connections are rejected even with streaming enabled: a provider can return a non-streaming response to a streaming request. Choose a connection configured for Chat Completions instead.
34
+
35
+ Install the package in the same Python environment as Open WebUI, then generate its Function import file:
36
+
37
+ ```bash
38
+ pip install traceAI-openwebui
39
+ traceai-openwebui export-function --output traceai-openwebui.json
40
+ ```
41
+
42
+ For a container deployment, install the package in your image and run the export command in that image. Make the generated JSON file available to your browser. The file contains a small package loader, not instrumentation source to maintain.
43
+
44
+ ## Configure the Function
45
+
46
+ 1. In **Admin Panel → Functions**, import `traceai-openwebui.json`.
47
+ 2. Open the **Traced model** Function's valves. Set `target_model` to the exact model ID from your existing Chat Completions connection. Responses API connections, native Ollama models, workspace model presets, other Pipes, and Arena models cannot be targets.
48
+ 3. Set `api_key`, `secret_key`, and `project` to your Future AGI credentials and project name. The default endpoint is `https://api.futureagi.com`. For self-hosted Future AGI, set its HTTPS origin; `/tracer/v1/traces` is added automatically.
49
+ 4. Enable the Function, and select **Traced model** in a new chat. Access to the target model is still checked using the signed-in Open WebUI user.
50
+
51
+ Set `ENABLE_VALVE_ENCRYPTION=true` in Open WebUI before storing production credentials so its database encrypts the valves.
52
+
53
+ Use the wrapper model's settings for tools and knowledge. Target-model workspace presets are not inherited. Enable **Usage** in the wrapper model's capabilities when your provider supports streamed token counts. Missing usage stays missing; the integration does not estimate it.
54
+
55
+ Send a message such as “Explain how a cache works in two sentences.” In Future AGI, open **Observe**, choose your project, and inspect an `openwebui.model` span. Native tool loops that invoke this Pipe again produce a separate span for each actual model call, not one span for the whole conversation.
56
+
57
+ ## What is captured
58
+
59
+ - One `LLM` span per delegated model request, from dispatch through response consumption.
60
+ - Provider-reported input and output token counts on that call only. Repeated usage snapshots replace previous values; turn totals are never added again.
61
+ - Fixed error messages for failures, cancellation, or an incomplete stream. Provider exception text and stack traces are not exported.
62
+ - The active OpenTelemetry span as parent, when one exists. The package does not export Open WebUI's native HTTP, database, Redis, or authentication spans.
63
+
64
+ This is **model-call tracing**, not a trace of the entire chat workflow. Retrieval, tool execution, approval pauses, and calls to unwrapped models are outside the Pipe boundary. The integration does not fabricate spans for them or reconstruct model calls from the final transcript. If you also instrument the target gateway, avoid counting both its LLM spans and this Pipe's spans as separate model usage.
65
+
66
+ Prompts, responses, user identifiers, raw email, headers, URLs, tool arguments, and retrieval content are excluded by default. To include message content, enable `capture_content`; `max_chars` bounds each captured input/output to 32,000 characters by default. Temporary and local chats always exclude content. Model IDs and your configured project name are exported.
67
+
68
+ Open WebUI's own telemetry is independent. Enabling it can export additional personal data through its separately configured exporter; this package does not sanitize that exporter.
69
+
70
+ ## Replace the old Filter
71
+
72
+ Disable and delete the old Future AGI Filter Function before enabling this Pipe. The old filter recorded whole-turn summaries as an LLM child and could not observe failed or cancelled turns reliably. The Pipe is not a drop-in filter: choose **Traced model** for the calls you want to trace. Remove any separate tracing filter or gateway integration that would duplicate usage.
73
+
74
+ Existing Function copies in Open WebUI's database do not change when this repository changes. Import the generated Function after each package upgrade, then restart Open WebUI to load the new Python package.
75
+
76
+ ## Troubleshooting
77
+
78
+ **No spans:** Confirm you selected **Traced model**, supplied both Future AGI keys, and enabled the Function. Tracing configuration/export failures do not block the model response. Allow a few seconds for the batch exporter.
79
+
80
+ **Target model rejected:** Use an OpenAI-compatible Chat Completions connection model ID, not a Responses API connection, native Ollama model, workspace preset, Arena model, or another Pipe. The signed-in user must also have permission to use that target.
81
+
82
+ **Invalid non-streaming response:** The provider returned a scalar, array, or undecodable body instead of a completion object. Check the connection URL and API type; the Pipe returns an explicit error rather than presenting that body as generated text.
83
+
84
+ **No token counts:** Enable Usage on the wrapper model and check that the provider returns usage. Cancelled calls may never receive a final usage snapshot.
85
+
86
+ **Incomplete stream:** The provider ended its stream without confirmed completion, or the consumer closed it early. The span is not marked successful. When the request explicitly supplies a positive integer `n`, every requested choice must finish unless the provider sends `[DONE]`. Without explicit `n`, the Pipe requires `[DONE]`: native model defaults can change the choice count after dispatch, so a single finished choice does not prove the request completed. If your provider omits `[DONE]`, supply the intended `n` in the wrapper request rather than relying on target-model defaults. This does not change the response sent to your chat. An abrupt process kill cannot export pending spans.
87
+
88
+ **Oversized streaming event:** The Pipe inspects up to 1,048,576 decoded characters per SSE event, including framing and line endings. Larger events still reach your chat unchanged, but the Pipe discards them from telemetry parsing to bound its buffer. The span records `openwebui.observation_lost=true` and an `incomplete` outcome with error status, even if `[DONE]` or finished choices are observed; a separately observed failure or cancellation retains its own outcome. This indicates incomplete observation, not proof that the provider failed. Content and usage from discarded events are unavailable; token counts reflect only observed snapshots. Configure your provider to send smaller streaming events if you need complete telemetry. The `max_chars` content-capture setting does not change this inspection limit.
89
+
90
+ **Self-hosted collector:** Remote endpoints must use HTTPS; HTTP is accepted only for loopback testing. Redirects, host proxy settings, `.netrc`, and unrelated native OTLP client certificates are not used. Use a directly reachable collector endpoint.
91
+
92
+ ## Embedding lifecycle
93
+
94
+ If you manage the Pipe lifecycle in Python, `pipe.flush(timeout_millis=10000)` bounds the caller's wait and returns `False` when the flush does not complete within that budget. An in-progress export can continue in the background; retrying does not create concurrent flush workers. Call `pipe.close()` to retire the exporter after active model calls finish.
@@ -0,0 +1,70 @@
1
+ # traceAI-openwebui
2
+
3
+ Trace each model call made through an Open WebUI Pipe Function in Future AGI, with streaming duration, provider-reported usage, and failure or cancellation status.
4
+
5
+ ## Install
6
+
7
+ Use Python 3.11 or 3.12 and Open WebUI 0.11.4–0.12.x. Configure an OpenAI-compatible **Chat Completions** connection model in Open WebUI first.
8
+
9
+ Native Ollama and **Responses API** connections are not supported. Open WebUI's conversions can hide failures or incomplete responses, so the Pipe rejects these connections before making a model request or recording a span. Responses API connections are rejected even with streaming enabled: a provider can return a non-streaming response to a streaming request. Choose a connection configured for Chat Completions instead.
10
+
11
+ Install the package in the same Python environment as Open WebUI, then generate its Function import file:
12
+
13
+ ```bash
14
+ pip install traceAI-openwebui
15
+ traceai-openwebui export-function --output traceai-openwebui.json
16
+ ```
17
+
18
+ For a container deployment, install the package in your image and run the export command in that image. Make the generated JSON file available to your browser. The file contains a small package loader, not instrumentation source to maintain.
19
+
20
+ ## Configure the Function
21
+
22
+ 1. In **Admin Panel → Functions**, import `traceai-openwebui.json`.
23
+ 2. Open the **Traced model** Function's valves. Set `target_model` to the exact model ID from your existing Chat Completions connection. Responses API connections, native Ollama models, workspace model presets, other Pipes, and Arena models cannot be targets.
24
+ 3. Set `api_key`, `secret_key`, and `project` to your Future AGI credentials and project name. The default endpoint is `https://api.futureagi.com`. For self-hosted Future AGI, set its HTTPS origin; `/tracer/v1/traces` is added automatically.
25
+ 4. Enable the Function, and select **Traced model** in a new chat. Access to the target model is still checked using the signed-in Open WebUI user.
26
+
27
+ Set `ENABLE_VALVE_ENCRYPTION=true` in Open WebUI before storing production credentials so its database encrypts the valves.
28
+
29
+ Use the wrapper model's settings for tools and knowledge. Target-model workspace presets are not inherited. Enable **Usage** in the wrapper model's capabilities when your provider supports streamed token counts. Missing usage stays missing; the integration does not estimate it.
30
+
31
+ Send a message such as “Explain how a cache works in two sentences.” In Future AGI, open **Observe**, choose your project, and inspect an `openwebui.model` span. Native tool loops that invoke this Pipe again produce a separate span for each actual model call, not one span for the whole conversation.
32
+
33
+ ## What is captured
34
+
35
+ - One `LLM` span per delegated model request, from dispatch through response consumption.
36
+ - Provider-reported input and output token counts on that call only. Repeated usage snapshots replace previous values; turn totals are never added again.
37
+ - Fixed error messages for failures, cancellation, or an incomplete stream. Provider exception text and stack traces are not exported.
38
+ - The active OpenTelemetry span as parent, when one exists. The package does not export Open WebUI's native HTTP, database, Redis, or authentication spans.
39
+
40
+ This is **model-call tracing**, not a trace of the entire chat workflow. Retrieval, tool execution, approval pauses, and calls to unwrapped models are outside the Pipe boundary. The integration does not fabricate spans for them or reconstruct model calls from the final transcript. If you also instrument the target gateway, avoid counting both its LLM spans and this Pipe's spans as separate model usage.
41
+
42
+ Prompts, responses, user identifiers, raw email, headers, URLs, tool arguments, and retrieval content are excluded by default. To include message content, enable `capture_content`; `max_chars` bounds each captured input/output to 32,000 characters by default. Temporary and local chats always exclude content. Model IDs and your configured project name are exported.
43
+
44
+ Open WebUI's own telemetry is independent. Enabling it can export additional personal data through its separately configured exporter; this package does not sanitize that exporter.
45
+
46
+ ## Replace the old Filter
47
+
48
+ Disable and delete the old Future AGI Filter Function before enabling this Pipe. The old filter recorded whole-turn summaries as an LLM child and could not observe failed or cancelled turns reliably. The Pipe is not a drop-in filter: choose **Traced model** for the calls you want to trace. Remove any separate tracing filter or gateway integration that would duplicate usage.
49
+
50
+ Existing Function copies in Open WebUI's database do not change when this repository changes. Import the generated Function after each package upgrade, then restart Open WebUI to load the new Python package.
51
+
52
+ ## Troubleshooting
53
+
54
+ **No spans:** Confirm you selected **Traced model**, supplied both Future AGI keys, and enabled the Function. Tracing configuration/export failures do not block the model response. Allow a few seconds for the batch exporter.
55
+
56
+ **Target model rejected:** Use an OpenAI-compatible Chat Completions connection model ID, not a Responses API connection, native Ollama model, workspace preset, Arena model, or another Pipe. The signed-in user must also have permission to use that target.
57
+
58
+ **Invalid non-streaming response:** The provider returned a scalar, array, or undecodable body instead of a completion object. Check the connection URL and API type; the Pipe returns an explicit error rather than presenting that body as generated text.
59
+
60
+ **No token counts:** Enable Usage on the wrapper model and check that the provider returns usage. Cancelled calls may never receive a final usage snapshot.
61
+
62
+ **Incomplete stream:** The provider ended its stream without confirmed completion, or the consumer closed it early. The span is not marked successful. When the request explicitly supplies a positive integer `n`, every requested choice must finish unless the provider sends `[DONE]`. Without explicit `n`, the Pipe requires `[DONE]`: native model defaults can change the choice count after dispatch, so a single finished choice does not prove the request completed. If your provider omits `[DONE]`, supply the intended `n` in the wrapper request rather than relying on target-model defaults. This does not change the response sent to your chat. An abrupt process kill cannot export pending spans.
63
+
64
+ **Oversized streaming event:** The Pipe inspects up to 1,048,576 decoded characters per SSE event, including framing and line endings. Larger events still reach your chat unchanged, but the Pipe discards them from telemetry parsing to bound its buffer. The span records `openwebui.observation_lost=true` and an `incomplete` outcome with error status, even if `[DONE]` or finished choices are observed; a separately observed failure or cancellation retains its own outcome. This indicates incomplete observation, not proof that the provider failed. Content and usage from discarded events are unavailable; token counts reflect only observed snapshots. Configure your provider to send smaller streaming events if you need complete telemetry. The `max_chars` content-capture setting does not change this inspection limit.
65
+
66
+ **Self-hosted collector:** Remote endpoints must use HTTPS; HTTP is accepted only for loopback testing. Redirects, host proxy settings, `.netrc`, and unrelated native OTLP client certificates are not used. Use a directly reachable collector endpoint.
67
+
68
+ ## Embedding lifecycle
69
+
70
+ If you manage the Pipe lifecycle in Python, `pipe.flush(timeout_millis=10000)` bounds the caller's wait and returns `False` when the flush does not complete within that budget. An in-progress export can continue in the background; retrying does not create concurrent flush workers. Call `pipe.close()` to retire the exporter after active model calls finish.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "traceAI-openwebui"
7
+ version = "0.1.0"
8
+ description = "Trace real Open WebUI model calls with a maintained Pipe Function"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ authors = [{name = "Future AGI", email = "no-reply@futureagi.com"}]
12
+ requires-python = ">=3.11,<3.13"
13
+ dependencies = [
14
+ "opentelemetry-sdk>=1.27,<2",
15
+ "opentelemetry-exporter-otlp-proto-http>=1.27,<2",
16
+ "pydantic>=2.9,<3",
17
+ "starlette>=0.40,<1",
18
+ "packaging>=24,<27",
19
+ "certifi>=2024.7.4",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["pytest>=8,<9", "pytest-asyncio>=0.24,<1", "build>=1.2,<2", "ruff>=0.14,<1", "mypy>=1.18,<2", "types-requests>=2.32"]
24
+
25
+ [project.scripts]
26
+ traceai-openwebui = "traceai_openwebui.cli:main"
27
+
28
+ [project.urls]
29
+ Repository = "https://github.com/future-agi/traceAI"
30
+ Documentation = "https://docs.futureagi.com/docs/integrations/no-code/open-webui"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["traceai_openwebui"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ asyncio_mode = "auto"
38
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,167 @@
1
+ import ast
2
+ import inspect
3
+ import json
4
+ import logging
5
+ import os
6
+ import sys
7
+ import time
8
+ import uuid
9
+ from collections.abc import AsyncGenerator, Generator, Iterator
10
+ from pathlib import Path
11
+ from types import ModuleType, SimpleNamespace
12
+ from typing import Any
13
+ from unittest.mock import AsyncMock
14
+ from zipfile import ZipFile
15
+
16
+ import pytest
17
+ import traceai_openwebui
18
+ from opentelemetry.sdk.trace import TracerProvider
19
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
20
+ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
21
+ from pydantic import BaseModel, SecretStr
22
+ from starlette.responses import Response, StreamingResponse
23
+
24
+
25
+ @pytest.fixture
26
+ def setup_pipe(monkeypatch):
27
+ modules = {}
28
+ for name in (
29
+ "open_webui",
30
+ "open_webui.models",
31
+ "open_webui.models.users",
32
+ "open_webui.routers",
33
+ "open_webui.routers.openai",
34
+ "open_webui.utils",
35
+ "open_webui.utils.chat",
36
+ ):
37
+ modules[name] = ModuleType(name)
38
+ monkeypatch.setitem(sys.modules, name, modules[name])
39
+ user = SimpleNamespace(
40
+ id="synthetic-user",
41
+ role="user",
42
+ model_dump=lambda: {"id": "synthetic-user", "email": "secret@example.invalid"},
43
+ )
44
+ modules["open_webui.models.users"].Users = SimpleNamespace(
45
+ get_user_by_id=AsyncMock(return_value=user)
46
+ )
47
+ delegate = AsyncMock(
48
+ return_value={
49
+ "choices": [{"message": {"content": "private-output"}}],
50
+ "usage": {"prompt_tokens": 7, "completion_tokens": 3},
51
+ }
52
+ )
53
+ modules["open_webui.utils.chat"].generate_chat_completion = delegate
54
+ modules["open_webui.routers.openai"].get_all_models = AsyncMock()
55
+ modules["open_webui.routers.openai"].get_openai_connection = AsyncMock(
56
+ return_value=("http://127.0.0.1/v1", "synthetic-key", {})
57
+ )
58
+ monkeypatch.setattr(traceai_openwebui, "version", lambda name: "0.12.5")
59
+ exporter = InMemorySpanExporter()
60
+ provider = TracerProvider()
61
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
62
+ pipe = traceai_openwebui.Pipe()
63
+ pipe.valves.target_model = "synthetic"
64
+ pipe.valves.api_key = SecretStr("synthetic-api")
65
+ pipe.valves.secret_key = SecretStr("synthetic-secret")
66
+ monkeypatch.setattr(
67
+ pipe._providers, "acquire", lambda key: (provider, lambda: None)
68
+ )
69
+ request = SimpleNamespace(
70
+ app=SimpleNamespace(
71
+ state=SimpleNamespace(
72
+ OPENAI_MODELS={"synthetic": {"id": "synthetic", "urlIdx": 0}},
73
+ MODELS={
74
+ "synthetic": {"id": "synthetic", "owned_by": "openai"},
75
+ "traceai_openwebui": {
76
+ "id": "traceai_openwebui",
77
+ "pipe": True,
78
+ "owned_by": "openai",
79
+ },
80
+ },
81
+ )
82
+ ),
83
+ state=SimpleNamespace(),
84
+ )
85
+ body = {
86
+ "model": "traceai_openwebui",
87
+ "messages": [{"role": "user", "content": "private-input"}],
88
+ }
89
+ yield SimpleNamespace(
90
+ pipe=pipe,
91
+ request=request,
92
+ body=body,
93
+ user=user,
94
+ delegate=delegate,
95
+ modules=modules,
96
+ provider=provider,
97
+ exporter=exporter,
98
+ )
99
+ provider.shutdown()
100
+
101
+
102
+ @pytest.fixture
103
+ def host(setup_pipe, monkeypatch):
104
+ s = setup_pipe
105
+ path = os.environ.get("OPENWEBUI_WHEEL")
106
+ if not path:
107
+ pytest.skip(
108
+ "Set OPENWEBUI_WHEEL to run the real released-host dispatch contract"
109
+ )
110
+ with ZipFile(Path(path)) as wheel:
111
+ metadata_path = next(
112
+ n for n in wheel.namelist() if n.endswith(".dist-info/METADATA")
113
+ )
114
+ version = next(
115
+ line[9:]
116
+ for line in wheel.read(metadata_path).decode().splitlines()
117
+ if line.startswith("Version: ")
118
+ )
119
+ monkeypatch.setattr("traceai_openwebui.version", lambda name: version)
120
+ ns = {
121
+ "Any": Any,
122
+ "Request": Any,
123
+ "Response": Response,
124
+ "StreamingResponse": StreamingResponse,
125
+ "inspect": inspect,
126
+ "time": time,
127
+ "uuid": uuid,
128
+ "BaseModel": BaseModel,
129
+ "JSONCodec": SimpleNamespace(dumps=json.dumps, loads=json.loads),
130
+ "Generator": Generator,
131
+ "AsyncGenerator": AsyncGenerator,
132
+ "Iterator": Iterator,
133
+ "BYPASS_MODEL_ACCESS_CONTROL": False,
134
+ "BYPASS_ADMIN_ACCESS_CONTROL": False,
135
+ "log": logging.getLogger("host_contract"),
136
+ "UserModel": type(s.user),
137
+ "Models": SimpleNamespace(get_model_by_id=AsyncMock(return_value=None)),
138
+ "get_system_oauth_token": AsyncMock(return_value=None),
139
+ "get_event_emitter": AsyncMock(return_value=None),
140
+ "get_event_call": AsyncMock(return_value=None),
141
+ "check_model_access": AsyncMock(),
142
+ "get_function_module_by_id": AsyncMock(return_value=s.pipe),
143
+ "generate_openai_chat_completion": s.delegate,
144
+ }
145
+ for filename, name in (
146
+ ("open_webui/functions.py", "generate_function_chat_completion"),
147
+ ("open_webui/utils/chat.py", "generate_chat_completion"),
148
+ ("open_webui/utils/misc.py", "openai_chat_message_template"),
149
+ ("open_webui/utils/misc.py", "openai_chat_completion_message_template"),
150
+ ("open_webui/utils/misc.py", "openai_chat_chunk_message_template"),
151
+ ):
152
+ module = ast.parse(wheel.read(filename).decode(), filename=filename)
153
+ node = next(
154
+ n
155
+ for n in module.body
156
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
157
+ and n.name == name
158
+ )
159
+ # Compile the entire unchanged function, never rewritten hook snippets.
160
+ exec( # noqa: S102 - execute released host contract only in isolated tests
161
+ compile(ast.Module(body=[node], type_ignores=[]), filename, "exec"), ns
162
+ )
163
+ s.request.cookies = {}
164
+ s.modules["open_webui.utils.chat"].generate_chat_completion = ns[
165
+ "generate_chat_completion"
166
+ ]
167
+ return s, ns
@@ -0,0 +1,180 @@
1
+ """Released host dispatch and streaming contract, not a full application test."""
2
+
3
+ import asyncio
4
+ from unittest.mock import AsyncMock
5
+
6
+ import pytest
7
+ from starlette.responses import JSONResponse, Response, StreamingResponse
8
+ from test_pipe import frame, iterator
9
+
10
+
11
+ async def test_released_host_nonstream_and_access(host):
12
+ s, ns = host
13
+ result = await ns["generate_chat_completion"](s.request, s.body, s.user)
14
+ assert result is s.delegate.return_value
15
+ assert [call.args[1]["id"] for call in ns["check_model_access"].call_args_list] == [
16
+ "traceai_openwebui",
17
+ "synthetic",
18
+ ]
19
+ (span,) = s.exporter.get_finished_spans()
20
+ assert span.attributes["llm.token_count.total"] == 10
21
+
22
+
23
+ async def test_released_host_stream_is_not_double_framed(host):
24
+ s, ns = host
25
+ chunks = [
26
+ frame(
27
+ {"choices": [{"delta": {"content": "synthetic"}, "finish_reason": "stop"}]}
28
+ ),
29
+ frame({"usage": {"input_tokens": 6, "output_tokens": 2}}),
30
+ "data: [DONE]\n\n",
31
+ ]
32
+ s.delegate.return_value = StreamingResponse(
33
+ iterator(chunks), media_type="text/event-stream"
34
+ )
35
+ result = await ns["generate_chat_completion"](
36
+ s.request, {**s.body, "stream": True}, s.user
37
+ )
38
+ assert [chunk async for chunk in result.body_iterator] == chunks
39
+ (span,) = s.exporter.get_finished_spans()
40
+ assert span.attributes["llm.token_count.total"] == 8
41
+
42
+
43
+ async def test_released_host_stream_cancellation(host):
44
+ s, ns = host
45
+ waiting = asyncio.Event()
46
+
47
+ async def source():
48
+ waiting.set()
49
+ await asyncio.Event().wait()
50
+ yield "unreachable"
51
+
52
+ s.delegate.return_value = StreamingResponse(source())
53
+ result = await ns["generate_chat_completion"](
54
+ s.request, {**s.body, "stream": True}, s.user
55
+ )
56
+
57
+ async def consume():
58
+ return [chunk async for chunk in result.body_iterator]
59
+
60
+ task = asyncio.create_task(consume())
61
+ await waiting.wait()
62
+ task.cancel()
63
+ with pytest.raises(asyncio.CancelledError):
64
+ await task
65
+ (span,) = s.exporter.get_finished_spans()
66
+ assert span.attributes["openwebui.outcome"] == "cancelled"
67
+
68
+
69
+ async def test_released_host_failure_is_traced_even_if_host_catches_it(host):
70
+ s, ns = host
71
+ s.delegate.side_effect = RuntimeError("synthetic provider failure")
72
+ result = await ns["generate_chat_completion"](s.request, s.body, s.user)
73
+ assert "error" in result
74
+ (span,) = s.exporter.get_finished_spans()
75
+ assert span.status.description == "model request failed"
76
+
77
+
78
+ async def test_released_host_access_failure_never_calls_provider(host):
79
+ s, ns = host
80
+
81
+ async def check(user, model):
82
+ if model["id"] == "synthetic":
83
+ raise PermissionError("synthetic denial")
84
+
85
+ ns["check_model_access"].side_effect = check
86
+ result = await ns["generate_chat_completion"](s.request, s.body, s.user)
87
+ assert "error" in result
88
+ s.delegate.assert_not_awaited()
89
+ (span,) = s.exporter.get_finished_spans()
90
+ assert span.status.description == "model request failed"
91
+
92
+
93
+ @pytest.mark.parametrize("stream", [False, True])
94
+ @pytest.mark.parametrize(
95
+ "result",
96
+ [
97
+ JSONResponse({"error": {"message": "synthetic denial"}}, status_code=429),
98
+ JSONResponse({"detail": "synthetic denial"}, status_code=403),
99
+ Response(b"synthetic unavailable", status_code=503),
100
+ Response(b"not a completion", status_code=200),
101
+ JSONResponse(["not a completion"], status_code=200),
102
+ JSONResponse({}, status_code=200),
103
+ JSONResponse({"choices": [{"delta": {"content": "not a completion"}}]}),
104
+ ],
105
+ )
106
+ async def test_released_host_http_error_is_not_empty_success(host, stream, result):
107
+ s, ns = host
108
+ s.delegate.return_value = result
109
+ response = await ns["generate_chat_completion"](
110
+ s.request, {**s.body, "stream": stream}, s.user
111
+ )
112
+ if stream:
113
+ chunks = [x async for x in response.body_iterator]
114
+ assert len(chunks) == 1 and '"error"' in chunks[0]
115
+ assert "finish_reason" not in chunks[0]
116
+ else:
117
+ assert isinstance(response, dict) and response["error"]
118
+ assert "choices" not in response
119
+ (span,) = s.exporter.get_finished_spans()
120
+ assert span.status.description == "model request failed"
121
+
122
+
123
+ async def test_released_host_nonstream_json_response_preserves_completion(host):
124
+ s, ns = host
125
+ completion = s.delegate.return_value
126
+ s.delegate.return_value = JSONResponse(completion)
127
+ response = await ns["generate_chat_completion"](s.request, s.body, s.user)
128
+ assert response == completion
129
+ (span,) = s.exporter.get_finished_spans()
130
+ assert span.attributes["llm.token_count.total"] == 10
131
+
132
+
133
+ @pytest.mark.parametrize("stream", [False, True])
134
+ async def test_released_host_native_ollama_is_rejected_before_dispatch(host, stream):
135
+ s, ns = host
136
+ s.request.app.state.MODELS["synthetic"]["owned_by"] = "ollama"
137
+ native = ns["generate_ollama_chat_completion"] = AsyncMock()
138
+ response = await ns["generate_chat_completion"](
139
+ s.request, {**s.body, "stream": stream}, s.user
140
+ )
141
+ if stream:
142
+ chunks = [x async for x in response.body_iterator]
143
+ assert len(chunks) == 1 and "Native Ollama" in chunks[0]
144
+ assert '"error"' in chunks[0] and "finish_reason" not in chunks[0]
145
+ else:
146
+ assert "Native Ollama" in str(response["error"])
147
+ native.assert_not_awaited()
148
+ s.delegate.assert_not_awaited()
149
+ assert not s.exporter.get_finished_spans()
150
+
151
+
152
+ async def test_released_host_shielded_cancellation_cleanup(host):
153
+ s, ns = host
154
+ s.delegate.return_value = StreamingResponse(
155
+ iterator(["data: {}\n\n", "data: [DONE]\n\n"])
156
+ )
157
+ response = await ns["generate_chat_completion"](
158
+ s.request, {**s.body, "stream": True}, s.user
159
+ )
160
+ waiting = asyncio.Event()
161
+
162
+ async def consume():
163
+ try:
164
+ await anext(response.body_iterator)
165
+ waiting.set()
166
+ await asyncio.Event().wait()
167
+ except asyncio.CancelledError:
168
+ # Released middleware closes the outer dispatcher under shield BEFORE
169
+ # the consuming task becomes cancelled. Its inner stream is finalized.
170
+ await asyncio.shield(response.body_iterator.aclose())
171
+ raise
172
+
173
+ task = asyncio.create_task(consume())
174
+ await waiting.wait()
175
+ task.cancel()
176
+ with pytest.raises(asyncio.CancelledError):
177
+ await task
178
+ (span,) = s.exporter.get_finished_spans()
179
+ assert span.attributes["openwebui.outcome"] == "cancelled"
180
+ assert span.status.description == "model request cancelled"