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.
- traceai_openwebui-0.1.0/.gitignore +87 -0
- traceai_openwebui-0.1.0/PKG-INFO +94 -0
- traceai_openwebui-0.1.0/README.md +70 -0
- traceai_openwebui-0.1.0/pyproject.toml +38 -0
- traceai_openwebui-0.1.0/tests/conftest.py +167 -0
- traceai_openwebui-0.1.0/tests/test_host_contract.py +180 -0
- traceai_openwebui-0.1.0/tests/test_native_router.py +654 -0
- traceai_openwebui-0.1.0/tests/test_pipe.py +422 -0
- traceai_openwebui-0.1.0/tests/test_response.py +123 -0
- traceai_openwebui-0.1.0/tests/test_transport.py +283 -0
- traceai_openwebui-0.1.0/traceai_openwebui/__init__.py +359 -0
- traceai_openwebui-0.1.0/traceai_openwebui/_provider.py +210 -0
- traceai_openwebui-0.1.0/traceai_openwebui/_response.py +234 -0
- traceai_openwebui-0.1.0/traceai_openwebui/cli.py +42 -0
|
@@ -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"
|