traceAI-openwebui 0.1.0__py3-none-any.whl
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/__init__.py +359 -0
- traceai_openwebui/_provider.py +210 -0
- traceai_openwebui/_response.py +234 -0
- traceai_openwebui/cli.py +42 -0
- traceai_openwebui-0.1.0.dist-info/METADATA +94 -0
- traceai_openwebui-0.1.0.dist-info/RECORD +8 -0
- traceai_openwebui-0.1.0.dist-info/WHEEL +4 -0
- traceai_openwebui-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Maintained Open WebUI Pipe Function for real, individually timed model calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import copy
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import weakref
|
|
10
|
+
from contextlib import nullcontext
|
|
11
|
+
from importlib.metadata import version
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from opentelemetry import trace
|
|
15
|
+
from opentelemetry.trace import SpanKind, Status, StatusCode
|
|
16
|
+
from packaging.version import Version
|
|
17
|
+
from pydantic import BaseModel, Field, SecretStr, field_serializer
|
|
18
|
+
from starlette.responses import Response, StreamingResponse
|
|
19
|
+
|
|
20
|
+
from ._provider import Providers
|
|
21
|
+
from ._response import Observation, is_chat_completion
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0" # x-release-please-version
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _check_host() -> None:
|
|
28
|
+
installed = Version(version("open-webui"))
|
|
29
|
+
if not Version("0.11.4") <= installed < Version("0.13"):
|
|
30
|
+
raise RuntimeError("traceAI-openwebui requires Open WebUI >=0.11.4,<0.13")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _secret() -> Any:
|
|
34
|
+
return Field(
|
|
35
|
+
default=SecretStr(""), json_schema_extra={"input": {"type": "password"}}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _check_connection(request: Any, user: Any, model_id: str) -> None:
|
|
40
|
+
from open_webui.routers.openai import get_all_models, get_openai_connection
|
|
41
|
+
|
|
42
|
+
models = request.app.state.OPENAI_MODELS
|
|
43
|
+
if not models or model_id not in models:
|
|
44
|
+
await get_all_models(request, user=user)
|
|
45
|
+
models = request.app.state.OPENAI_MODELS
|
|
46
|
+
model = models.get(model_id)
|
|
47
|
+
if model is None:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"The target model's OpenAI-compatible connection is unavailable"
|
|
50
|
+
)
|
|
51
|
+
# Use the host's resolver, including index/legacy URL configuration lookup.
|
|
52
|
+
# Never cache this check: an administrator can change the connection's API.
|
|
53
|
+
_, _, config = await get_openai_connection(model["urlIdx"])
|
|
54
|
+
if config.get("api_type") == "responses":
|
|
55
|
+
# The host discards status/error/incomplete_details when a Responses
|
|
56
|
+
# endpoint returns JSON. Even stream=True can receive that lossy result.
|
|
57
|
+
# Reject before inference/span creation rather than inventing success.
|
|
58
|
+
raise ValueError(
|
|
59
|
+
"Responses API connections are not supported; choose a Chat Completions connection"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Pipe:
|
|
64
|
+
class Valves(BaseModel):
|
|
65
|
+
target_model: str = Field(
|
|
66
|
+
default="",
|
|
67
|
+
description="Native Chat Completions model ID; not Responses API, Ollama, another Pipe or Arena.",
|
|
68
|
+
)
|
|
69
|
+
api_key: SecretStr = _secret()
|
|
70
|
+
secret_key: SecretStr = _secret()
|
|
71
|
+
endpoint: str = "https://api.futureagi.com"
|
|
72
|
+
project: str = "open-webui"
|
|
73
|
+
capture_content: bool = False
|
|
74
|
+
max_chars: int = Field(default=32000, ge=1, le=131072)
|
|
75
|
+
|
|
76
|
+
@field_serializer("api_key", "secret_key")
|
|
77
|
+
def stored_secret(self, value: SecretStr) -> str:
|
|
78
|
+
# Open WebUI persists valves through model_dump(). Repr stays masked.
|
|
79
|
+
return value.get_secret_value()
|
|
80
|
+
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
self.valves = self.Valves()
|
|
83
|
+
self._providers = Providers()
|
|
84
|
+
weakref.finalize(self, self._providers.close)
|
|
85
|
+
|
|
86
|
+
async def pipe(
|
|
87
|
+
self,
|
|
88
|
+
body: dict[str, Any],
|
|
89
|
+
__request__: Any,
|
|
90
|
+
__user__: dict[str, Any],
|
|
91
|
+
__metadata__: dict[str, Any] | None = None,
|
|
92
|
+
) -> Any:
|
|
93
|
+
_check_host()
|
|
94
|
+
from open_webui.models.users import Users
|
|
95
|
+
from open_webui.utils.chat import generate_chat_completion
|
|
96
|
+
|
|
97
|
+
valves = self.valves.model_copy(deep=True)
|
|
98
|
+
target = __request__.app.state.MODELS.get(valves.target_model)
|
|
99
|
+
# Recursive Pipes/Arena routes could produce nested spans for one request
|
|
100
|
+
# or recurse indefinitely. Delegate only to a native provider entry.
|
|
101
|
+
if (
|
|
102
|
+
not target
|
|
103
|
+
or target.get("pipe")
|
|
104
|
+
or target.get("owned_by") == "arena"
|
|
105
|
+
or target.get("info", {}).get("base_model_id")
|
|
106
|
+
or valves.target_model == body.get("model")
|
|
107
|
+
):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"Choose a native provider model as target_model, not a workspace preset, Pipe or Arena"
|
|
110
|
+
)
|
|
111
|
+
# The native Ollama dispatcher discards in-band errors, invents [DONE]
|
|
112
|
+
# at EOF and (on the floor host) invents missing usage as zero. Reject it
|
|
113
|
+
# before dispatch instead of presenting converted data as real telemetry.
|
|
114
|
+
if target.get("owned_by") == "ollama":
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"Native Ollama connections are not supported; choose an OpenAI-compatible connection"
|
|
117
|
+
)
|
|
118
|
+
user = await Users.get_user_by_id(__user__.get("id"))
|
|
119
|
+
if user is None:
|
|
120
|
+
raise ValueError("Open WebUI user is unavailable")
|
|
121
|
+
await _check_connection(__request__, user, valves.target_model)
|
|
122
|
+
payload = copy.deepcopy(body)
|
|
123
|
+
payload["model"] = valves.target_model
|
|
124
|
+
if __metadata__ is not None:
|
|
125
|
+
payload["metadata"] = dict(__metadata__)
|
|
126
|
+
metadata = __metadata__ or {}
|
|
127
|
+
temporary = str(metadata.get("chat_id", "")).startswith(
|
|
128
|
+
("temporary:", "local:")
|
|
129
|
+
)
|
|
130
|
+
capture = valves.capture_content and not temporary
|
|
131
|
+
span = None
|
|
132
|
+
release = lambda: None
|
|
133
|
+
try:
|
|
134
|
+
if (
|
|
135
|
+
valves.api_key.get_secret_value()
|
|
136
|
+
and valves.secret_key.get_secret_value()
|
|
137
|
+
):
|
|
138
|
+
provider, release = self._providers.acquire(
|
|
139
|
+
(
|
|
140
|
+
valves.endpoint,
|
|
141
|
+
valves.project,
|
|
142
|
+
valves.api_key.get_secret_value(),
|
|
143
|
+
valves.secret_key.get_secret_value(),
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
span = provider.get_tracer("traceai_openwebui", __version__).start_span(
|
|
147
|
+
"openwebui.model",
|
|
148
|
+
kind=SpanKind.CLIENT,
|
|
149
|
+
attributes={
|
|
150
|
+
"fi.span.kind": "LLM",
|
|
151
|
+
"gen_ai.operation.name": "chat",
|
|
152
|
+
"gen_ai.request.model": valves.target_model,
|
|
153
|
+
"llm.model_name": valves.target_model,
|
|
154
|
+
"openwebui.tracing.scope": "model_call",
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
except Exception: # noqa: BLE001 - telemetry fails open without logging private payloads
|
|
158
|
+
release()
|
|
159
|
+
release = lambda: None
|
|
160
|
+
log.warning("Open WebUI tracing unavailable; model execution continues")
|
|
161
|
+
# Native routing applies stored model defaults after this point, including
|
|
162
|
+
# custom n parameters. Only an explicit n has authoritative precedence;
|
|
163
|
+
# otherwise require [DONE] rather than guessing from the model cache.
|
|
164
|
+
observation = (
|
|
165
|
+
Observation(span, capture, valves.max_chars, payload.get("n"))
|
|
166
|
+
if span is not None
|
|
167
|
+
else None
|
|
168
|
+
)
|
|
169
|
+
if span is not None and capture:
|
|
170
|
+
messages = [
|
|
171
|
+
{"role": m.get("role"), "content": m.get("content")}
|
|
172
|
+
for m in payload.get("messages", [])
|
|
173
|
+
if isinstance(m, dict)
|
|
174
|
+
]
|
|
175
|
+
span.set_attribute(
|
|
176
|
+
"input.value",
|
|
177
|
+
json.dumps(messages, ensure_ascii=False)[: valves.max_chars],
|
|
178
|
+
)
|
|
179
|
+
span.set_attribute("input.mime_type", "application/json")
|
|
180
|
+
|
|
181
|
+
def active():
|
|
182
|
+
return (
|
|
183
|
+
trace.use_span(
|
|
184
|
+
span,
|
|
185
|
+
end_on_exit=False,
|
|
186
|
+
record_exception=False,
|
|
187
|
+
set_status_on_exception=False,
|
|
188
|
+
)
|
|
189
|
+
if span is not None
|
|
190
|
+
else nullcontext()
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
watched_tasks: weakref.WeakSet[asyncio.Task] = weakref.WeakSet()
|
|
194
|
+
|
|
195
|
+
def observe(method: str, *args: Any) -> None:
|
|
196
|
+
if observation is not None:
|
|
197
|
+
try:
|
|
198
|
+
getattr(observation, method)(*args)
|
|
199
|
+
except Exception: # noqa: BLE001 - telemetry fails open without logging private payloads
|
|
200
|
+
log.warning("Open WebUI response telemetry unavailable")
|
|
201
|
+
|
|
202
|
+
def finish(outcome: str) -> None:
|
|
203
|
+
nonlocal span
|
|
204
|
+
try:
|
|
205
|
+
if span is not None:
|
|
206
|
+
# Host cleanup may abandon our stream under shield before the
|
|
207
|
+
# cancelled consumer finishes. A pending cancel request is
|
|
208
|
+
# already known; do not overwrite it with "incomplete" on GC.
|
|
209
|
+
if outcome == "incomplete" and any(
|
|
210
|
+
task.cancelling() or task.cancelled() for task in watched_tasks
|
|
211
|
+
):
|
|
212
|
+
outcome = "cancelled"
|
|
213
|
+
observe("finish")
|
|
214
|
+
if observation is not None and observation.failed:
|
|
215
|
+
outcome = "error"
|
|
216
|
+
span.set_attribute("openwebui.outcome", outcome)
|
|
217
|
+
if outcome != "completed":
|
|
218
|
+
span.set_status(
|
|
219
|
+
Status(
|
|
220
|
+
StatusCode.ERROR,
|
|
221
|
+
{
|
|
222
|
+
"error": "model request failed",
|
|
223
|
+
"cancelled": "model request cancelled",
|
|
224
|
+
"incomplete": "model stream ended without completion",
|
|
225
|
+
}[outcome],
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
span.end()
|
|
229
|
+
span = None
|
|
230
|
+
finally:
|
|
231
|
+
for task in watched_tasks:
|
|
232
|
+
task.remove_done_callback(task_done)
|
|
233
|
+
watched_tasks.clear()
|
|
234
|
+
release()
|
|
235
|
+
|
|
236
|
+
def task_done(task: asyncio.Task) -> None:
|
|
237
|
+
# A host may cancel while handling a chunk, outside our __anext__.
|
|
238
|
+
if task.cancelled():
|
|
239
|
+
finish("cancelled")
|
|
240
|
+
|
|
241
|
+
def watch_task() -> None:
|
|
242
|
+
task = asyncio.current_task()
|
|
243
|
+
if span is not None and task is not None and task not in watched_tasks:
|
|
244
|
+
watched_tasks.add(task)
|
|
245
|
+
task.add_done_callback(task_done)
|
|
246
|
+
|
|
247
|
+
watch_task()
|
|
248
|
+
try:
|
|
249
|
+
with active():
|
|
250
|
+
# Preserve upstream access checks. Never pass bypass_filter=True.
|
|
251
|
+
response = await generate_chat_completion(
|
|
252
|
+
__request__,
|
|
253
|
+
payload,
|
|
254
|
+
user,
|
|
255
|
+
bypass_system_prompt=getattr(
|
|
256
|
+
__request__.state, "bypass_system_prompt", False
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
except asyncio.CancelledError:
|
|
260
|
+
finish("cancelled")
|
|
261
|
+
raise
|
|
262
|
+
except BaseException:
|
|
263
|
+
finish("error")
|
|
264
|
+
raise
|
|
265
|
+
|
|
266
|
+
if not isinstance(response, StreamingResponse):
|
|
267
|
+
data = response
|
|
268
|
+
if isinstance(response, Response):
|
|
269
|
+
try:
|
|
270
|
+
data = json.loads(response.body)
|
|
271
|
+
except (ValueError, TypeError):
|
|
272
|
+
data = None
|
|
273
|
+
invalid = not (
|
|
274
|
+
isinstance(data, dict)
|
|
275
|
+
and (data.get("error") or is_chat_completion(data))
|
|
276
|
+
)
|
|
277
|
+
http_error = isinstance(response, Response) and response.status_code >= 400
|
|
278
|
+
try:
|
|
279
|
+
observe("payload", data)
|
|
280
|
+
finally:
|
|
281
|
+
finish("error" if invalid or http_error else "completed")
|
|
282
|
+
# The native router normally returns decoded JSON, not a Response.
|
|
283
|
+
# Validate both forms even with tracing disabled: the Pipe dispatcher
|
|
284
|
+
# would otherwise fabricate an empty completion or treat a raw string
|
|
285
|
+
# as generated model content.
|
|
286
|
+
if http_error and (invalid or not data.get("error")):
|
|
287
|
+
return {
|
|
288
|
+
"error": {
|
|
289
|
+
"message": "model request failed",
|
|
290
|
+
"code": response.status_code,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if invalid:
|
|
294
|
+
return {"error": "model returned an invalid non-streaming response"}
|
|
295
|
+
return data
|
|
296
|
+
|
|
297
|
+
if observation is not None and response.status_code >= 400:
|
|
298
|
+
observation.failed = True
|
|
299
|
+
|
|
300
|
+
async def traced_stream():
|
|
301
|
+
watch_task()
|
|
302
|
+
outcome = "incomplete"
|
|
303
|
+
try:
|
|
304
|
+
iterator = response.body_iterator.__aiter__()
|
|
305
|
+
while True:
|
|
306
|
+
try:
|
|
307
|
+
# Do not leave the span attached across a yield: unrelated
|
|
308
|
+
# tool/retrieval work must not become a child of this call.
|
|
309
|
+
with active():
|
|
310
|
+
chunk = await iterator.__anext__()
|
|
311
|
+
except StopAsyncIteration:
|
|
312
|
+
if observation is not None:
|
|
313
|
+
observe("finish")
|
|
314
|
+
outcome = (
|
|
315
|
+
"completed" if observation.terminal else "incomplete"
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
outcome = "completed"
|
|
319
|
+
break
|
|
320
|
+
observe("chunk", chunk)
|
|
321
|
+
yield chunk
|
|
322
|
+
except (asyncio.CancelledError, GeneratorExit):
|
|
323
|
+
outcome = "cancelled"
|
|
324
|
+
raise
|
|
325
|
+
except BaseException:
|
|
326
|
+
outcome = "error"
|
|
327
|
+
raise
|
|
328
|
+
finally:
|
|
329
|
+
try:
|
|
330
|
+
close = getattr(response.body_iterator, "aclose", None)
|
|
331
|
+
if close is not None:
|
|
332
|
+
await close()
|
|
333
|
+
# Open WebUI's Pipe dispatcher consumes only body_iterator;
|
|
334
|
+
# it does not run the returned response's background task.
|
|
335
|
+
if response.background is not None:
|
|
336
|
+
await response.background()
|
|
337
|
+
finally:
|
|
338
|
+
finish(outcome)
|
|
339
|
+
|
|
340
|
+
# Preserve the original stream exactly, including errors and [DONE].
|
|
341
|
+
stream = traced_stream()
|
|
342
|
+
# Includes a consumer that abandons the returned response before starting
|
|
343
|
+
# iteration. This is incomplete observation, never claimed as success.
|
|
344
|
+
weakref.finalize(stream, finish, "incomplete")
|
|
345
|
+
return StreamingResponse(
|
|
346
|
+
stream,
|
|
347
|
+
status_code=response.status_code,
|
|
348
|
+
headers=dict(response.headers),
|
|
349
|
+
media_type=response.media_type,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def flush(self, timeout_millis: int = 10000) -> bool:
|
|
353
|
+
return self._providers.flush(timeout_millis)
|
|
354
|
+
|
|
355
|
+
def close(self) -> None:
|
|
356
|
+
self._providers.close()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
__all__ = ["Pipe"]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Private provider: never forward Open WebUI's HTTP, auth or database spans."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from concurrent.futures import Future, TimeoutError
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
import certifi
|
|
13
|
+
import requests
|
|
14
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
|
15
|
+
Compression,
|
|
16
|
+
OTLPSpanExporter,
|
|
17
|
+
)
|
|
18
|
+
from opentelemetry.sdk.resources import Resource
|
|
19
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
20
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
21
|
+
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def endpoint_url(value: str) -> str:
|
|
25
|
+
parsed = urlsplit(value)
|
|
26
|
+
if (
|
|
27
|
+
parsed.scheme not in {"https", "http"}
|
|
28
|
+
or not parsed.hostname
|
|
29
|
+
or parsed.username
|
|
30
|
+
or parsed.password
|
|
31
|
+
or parsed.query
|
|
32
|
+
or parsed.fragment
|
|
33
|
+
):
|
|
34
|
+
raise ValueError("Use a collector URL without credentials, query or fragment")
|
|
35
|
+
if parsed.scheme == "http" and parsed.hostname not in {
|
|
36
|
+
"localhost",
|
|
37
|
+
"127.0.0.1",
|
|
38
|
+
"::1",
|
|
39
|
+
}:
|
|
40
|
+
raise ValueError("Use HTTPS for a non-loopback collector")
|
|
41
|
+
return value.rstrip("/") + (
|
|
42
|
+
""
|
|
43
|
+
if parsed.path.rstrip("/").endswith("/tracer/v1/traces")
|
|
44
|
+
else "/tracer/v1/traces"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _ExportSession(requests.Session):
|
|
49
|
+
def __init__(self, auth_headers: dict[str, str]) -> None:
|
|
50
|
+
super().__init__()
|
|
51
|
+
self._auth_headers = auth_headers
|
|
52
|
+
|
|
53
|
+
def prepare_request(self, request):
|
|
54
|
+
prepared = super().prepare_request(request)
|
|
55
|
+
# Some SDK versions merge native OTEL_* headers even when headers were
|
|
56
|
+
# explicitly supplied. Allow only transport headers plus our own keys.
|
|
57
|
+
prepared.headers.clear()
|
|
58
|
+
prepared.headers.update(
|
|
59
|
+
{
|
|
60
|
+
"Content-Type": "application/x-protobuf",
|
|
61
|
+
"Content-Length": str(len(prepared.body or b"")),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
prepared.headers.update(self._auth_headers)
|
|
65
|
+
return prepared
|
|
66
|
+
|
|
67
|
+
def request(self, method, url, **kwargs):
|
|
68
|
+
# The SDK's transport can pass mTLS from OTEL_* environment variables
|
|
69
|
+
# on each request. Override at the public requests boundary, not by
|
|
70
|
+
# temporarily mutating the process environment used by native telemetry.
|
|
71
|
+
kwargs["cert"] = None
|
|
72
|
+
kwargs["allow_redirects"] = False
|
|
73
|
+
return super().request(method, url, **kwargs)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def make_provider(
|
|
77
|
+
endpoint: str, project: str, api_key: str, secret_key: str
|
|
78
|
+
) -> TracerProvider:
|
|
79
|
+
session = _ExportSession({"X-Api-Key": api_key, "X-Secret-Key": secret_key})
|
|
80
|
+
# Never inherit proxy credentials or .netrc authentication from the host.
|
|
81
|
+
session.trust_env = False
|
|
82
|
+
exporter = OTLPSpanExporter(
|
|
83
|
+
endpoint=endpoint_url(endpoint),
|
|
84
|
+
headers={"X-Api-Key": api_key, "X-Secret-Key": secret_key},
|
|
85
|
+
timeout=10,
|
|
86
|
+
compression=Compression.NoCompression,
|
|
87
|
+
certificate_file=certifi.where(),
|
|
88
|
+
session=session,
|
|
89
|
+
)
|
|
90
|
+
# An unrelated native OTLP client certificate must not be sent to our endpoint.
|
|
91
|
+
session.cert = None
|
|
92
|
+
provider = TracerProvider(
|
|
93
|
+
resource=Resource(
|
|
94
|
+
{
|
|
95
|
+
"project_name": project,
|
|
96
|
+
"project_type": "observe",
|
|
97
|
+
"service.name": "open-webui",
|
|
98
|
+
}
|
|
99
|
+
),
|
|
100
|
+
sampler=ALWAYS_ON,
|
|
101
|
+
)
|
|
102
|
+
provider.add_span_processor(
|
|
103
|
+
BatchSpanProcessor(
|
|
104
|
+
exporter,
|
|
105
|
+
max_queue_size=2048,
|
|
106
|
+
max_export_batch_size=512,
|
|
107
|
+
schedule_delay_millis=5000,
|
|
108
|
+
export_timeout_millis=10000,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
return provider
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _Generation:
|
|
115
|
+
def __init__(self, key: tuple[str, ...], provider: TracerProvider) -> None:
|
|
116
|
+
self.key = key
|
|
117
|
+
self.provider = provider
|
|
118
|
+
self.users = 0
|
|
119
|
+
self.retired = False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Providers:
|
|
123
|
+
"""Keep in-flight calls on their original project when an admin edits valves."""
|
|
124
|
+
|
|
125
|
+
def __init__(self) -> None:
|
|
126
|
+
self._lock = threading.Lock()
|
|
127
|
+
self._flush_lock = threading.Lock()
|
|
128
|
+
self._current: _Generation | None = None
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _retire(generation: _Generation) -> None:
|
|
132
|
+
threading.Thread(
|
|
133
|
+
target=generation.provider.shutdown,
|
|
134
|
+
name="traceai-openwebui-close",
|
|
135
|
+
daemon=True,
|
|
136
|
+
).start()
|
|
137
|
+
|
|
138
|
+
def acquire(
|
|
139
|
+
self, key: tuple[str, str, str, str]
|
|
140
|
+
) -> tuple[TracerProvider, Callable[[], None]]:
|
|
141
|
+
with self._lock:
|
|
142
|
+
current = self._current
|
|
143
|
+
if current is None or current.key != key:
|
|
144
|
+
new = _Generation(key, make_provider(*key))
|
|
145
|
+
if current is not None:
|
|
146
|
+
current.retired = True
|
|
147
|
+
if current.users == 0:
|
|
148
|
+
self._retire(current)
|
|
149
|
+
self._current = current = new
|
|
150
|
+
current.users += 1
|
|
151
|
+
released = False
|
|
152
|
+
|
|
153
|
+
def release() -> None:
|
|
154
|
+
nonlocal released
|
|
155
|
+
with self._lock:
|
|
156
|
+
if released:
|
|
157
|
+
return
|
|
158
|
+
released = True
|
|
159
|
+
current.users -= 1
|
|
160
|
+
if current.retired and current.users == 0:
|
|
161
|
+
self._retire(current)
|
|
162
|
+
|
|
163
|
+
return current.provider, release
|
|
164
|
+
|
|
165
|
+
def flush(self, timeout_millis: int = 10000) -> bool:
|
|
166
|
+
"""Bound the caller's wait, even when the SDK ignores its flush timeout."""
|
|
167
|
+
deadline = time.monotonic() + max(0, timeout_millis) / 1000
|
|
168
|
+
with self._lock:
|
|
169
|
+
provider = self._current.provider if self._current else None
|
|
170
|
+
if provider is None:
|
|
171
|
+
return True
|
|
172
|
+
# One worker at a time, including after timeout. Do not create a thread
|
|
173
|
+
# per retry or return a previous flush's result for newly queued spans.
|
|
174
|
+
if not self._flush_lock.acquire(timeout=max(0, deadline - time.monotonic())):
|
|
175
|
+
return False
|
|
176
|
+
result: Future[bool] = Future()
|
|
177
|
+
|
|
178
|
+
def flush_provider() -> None:
|
|
179
|
+
try:
|
|
180
|
+
result.set_result(
|
|
181
|
+
provider.force_flush(
|
|
182
|
+
max(1, int((deadline - time.monotonic()) * 1000))
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
except Exception: # noqa: BLE001 - fixed diagnostic; never export private errors
|
|
186
|
+
logging.getLogger(__name__).warning("Open WebUI tracing flush failed")
|
|
187
|
+
result.set_result(False)
|
|
188
|
+
finally:
|
|
189
|
+
self._flush_lock.release()
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
threading.Thread(
|
|
193
|
+
target=flush_provider, name="traceai-openwebui-flush", daemon=True
|
|
194
|
+
).start()
|
|
195
|
+
except BaseException:
|
|
196
|
+
self._flush_lock.release()
|
|
197
|
+
raise
|
|
198
|
+
try:
|
|
199
|
+
return result.result(timeout=max(0, deadline - time.monotonic()))
|
|
200
|
+
except TimeoutError:
|
|
201
|
+
# Export can finish in the daemon worker; it cannot hold up shutdown.
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
def close(self) -> None:
|
|
205
|
+
with self._lock:
|
|
206
|
+
current, self._current = self._current, None
|
|
207
|
+
if current is not None:
|
|
208
|
+
current.retired = True
|
|
209
|
+
if current.users == 0:
|
|
210
|
+
self._retire(current)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Observe provider responses, never reconstruct operations from a chat transcript."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from opentelemetry.trace import Span
|
|
10
|
+
|
|
11
|
+
_MAX_FRAME = 1_048_576
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_chat_completion(value: Any) -> bool:
|
|
15
|
+
"""Require completed messages, not just a JSON object or streaming deltas.
|
|
16
|
+
|
|
17
|
+
Compatible providers can omit IDs, timestamps, usage, role and finish_reason.
|
|
18
|
+
Empty/filtered content, tool calls, refusals and audio are still completions.
|
|
19
|
+
"""
|
|
20
|
+
if not isinstance(value, dict):
|
|
21
|
+
return False
|
|
22
|
+
choices = value.get("choices")
|
|
23
|
+
if not isinstance(choices, list) or not choices:
|
|
24
|
+
return False
|
|
25
|
+
for choice in choices:
|
|
26
|
+
if not isinstance(choice, dict):
|
|
27
|
+
return False
|
|
28
|
+
message = choice.get("message")
|
|
29
|
+
if not isinstance(message, dict):
|
|
30
|
+
return False
|
|
31
|
+
if "content" in message:
|
|
32
|
+
if message["content"] is not None and not isinstance(
|
|
33
|
+
message["content"], str
|
|
34
|
+
):
|
|
35
|
+
return False
|
|
36
|
+
elif isinstance(message.get("refusal"), str):
|
|
37
|
+
continue
|
|
38
|
+
elif isinstance(message.get("tool_calls"), list) and message["tool_calls"]:
|
|
39
|
+
if not all(
|
|
40
|
+
isinstance(call, dict) and call for call in message["tool_calls"]
|
|
41
|
+
):
|
|
42
|
+
return False
|
|
43
|
+
elif any(
|
|
44
|
+
isinstance(message.get(key), dict) and message[key]
|
|
45
|
+
for key in ("function_call", "audio")
|
|
46
|
+
):
|
|
47
|
+
continue
|
|
48
|
+
else:
|
|
49
|
+
return False
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Observation:
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
span: Span,
|
|
57
|
+
capture_content: bool,
|
|
58
|
+
max_chars: int,
|
|
59
|
+
choice_count: Any = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.span = span
|
|
62
|
+
self.capture_content = capture_content
|
|
63
|
+
self.max_chars = max_chars
|
|
64
|
+
self.usage: dict[str, int] = {}
|
|
65
|
+
self.output = ""
|
|
66
|
+
self._terminal = False
|
|
67
|
+
self._choice_count = (
|
|
68
|
+
choice_count if type(choice_count) is int and choice_count > 0 else 0
|
|
69
|
+
)
|
|
70
|
+
self._finished_choices: set[int] = set()
|
|
71
|
+
self._ambiguous_choices = self._choice_count == 0
|
|
72
|
+
self.failed = False
|
|
73
|
+
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
|
74
|
+
self._buffer = ""
|
|
75
|
+
self._data: list[str] = []
|
|
76
|
+
self._size = 0
|
|
77
|
+
self._dropping = False
|
|
78
|
+
self._observation_lost = False
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def terminal(self) -> bool:
|
|
82
|
+
# A discarded event may contain a provider error. Even [DONE] or fully
|
|
83
|
+
# finished choices cannot establish success after losing that evidence.
|
|
84
|
+
return not self._observation_lost and (
|
|
85
|
+
self._terminal
|
|
86
|
+
or (
|
|
87
|
+
not self._ambiguous_choices
|
|
88
|
+
and len(self._finished_choices) == self._choice_count
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def payload(self, value: Any) -> None:
|
|
93
|
+
if not isinstance(value, dict):
|
|
94
|
+
return
|
|
95
|
+
if value.get("error") or value.get("type") in {"error", "response.failed"}:
|
|
96
|
+
self.failed = True
|
|
97
|
+
response = value.get("response")
|
|
98
|
+
if isinstance(response, dict):
|
|
99
|
+
if response.get("error") or response.get("status") in {
|
|
100
|
+
"failed",
|
|
101
|
+
"incomplete",
|
|
102
|
+
"cancelled",
|
|
103
|
+
}:
|
|
104
|
+
self.failed = True
|
|
105
|
+
self._usage(response.get("usage"))
|
|
106
|
+
self._usage(value.get("usage"))
|
|
107
|
+
if value.get("type") in {
|
|
108
|
+
"response.completed",
|
|
109
|
+
"response.failed",
|
|
110
|
+
"response.incomplete",
|
|
111
|
+
}:
|
|
112
|
+
self._terminal = True
|
|
113
|
+
if value["type"] != "response.completed":
|
|
114
|
+
self.failed = True
|
|
115
|
+
if value.get("type") == "response.output_text.delta":
|
|
116
|
+
self._text(value.get("delta"))
|
|
117
|
+
choices = value.get("choices")
|
|
118
|
+
if isinstance(choices, list):
|
|
119
|
+
for choice in choices:
|
|
120
|
+
if not isinstance(choice, dict):
|
|
121
|
+
self._ambiguous_choices = True
|
|
122
|
+
continue
|
|
123
|
+
# A choice finishing is not a request-level terminator. Account
|
|
124
|
+
# for n even when a missing choice never appears on the wire.
|
|
125
|
+
index = choice.get(
|
|
126
|
+
"index",
|
|
127
|
+
0 if self._choice_count == 1 and len(choices) == 1 else None,
|
|
128
|
+
)
|
|
129
|
+
if type(index) is not int or not 0 <= index < self._choice_count:
|
|
130
|
+
# Cannot correlate ambiguous choices; require [DONE] instead.
|
|
131
|
+
self._ambiguous_choices = True
|
|
132
|
+
elif (
|
|
133
|
+
isinstance(choice.get("finish_reason"), str)
|
|
134
|
+
and choice["finish_reason"]
|
|
135
|
+
):
|
|
136
|
+
self._finished_choices.add(index)
|
|
137
|
+
else:
|
|
138
|
+
self._finished_choices.discard(index)
|
|
139
|
+
message = choice.get("delta") or choice.get("message")
|
|
140
|
+
if isinstance(message, dict):
|
|
141
|
+
self._text(message.get("content"))
|
|
142
|
+
# Responses API non-streaming output. Streamed output is captured only
|
|
143
|
+
# from deltas, not again from response.completed's repeated output.
|
|
144
|
+
if isinstance(value.get("output"), list):
|
|
145
|
+
for item in value["output"]:
|
|
146
|
+
if (
|
|
147
|
+
isinstance(item, dict)
|
|
148
|
+
and item.get("type") == "message"
|
|
149
|
+
and isinstance(item.get("content"), list)
|
|
150
|
+
):
|
|
151
|
+
for part in item["content"]:
|
|
152
|
+
if isinstance(part, dict) and part.get("type") == "output_text":
|
|
153
|
+
self._text(part.get("text"))
|
|
154
|
+
|
|
155
|
+
def _usage(self, usage: Any) -> None:
|
|
156
|
+
if not isinstance(usage, dict):
|
|
157
|
+
return
|
|
158
|
+
for key, aliases in (
|
|
159
|
+
("input", ("prompt_tokens", "input_tokens")),
|
|
160
|
+
("output", ("completion_tokens", "output_tokens")),
|
|
161
|
+
):
|
|
162
|
+
for alias in aliases:
|
|
163
|
+
value = usage.get(alias)
|
|
164
|
+
if type(value) is int and 0 <= value < 2**63:
|
|
165
|
+
# A provider's usage object is a snapshot, not a delta.
|
|
166
|
+
self.usage[key] = value
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
def _text(self, value: Any) -> None:
|
|
170
|
+
if self.capture_content and isinstance(value, str):
|
|
171
|
+
self.output += value[: max(0, self.max_chars - len(self.output))]
|
|
172
|
+
|
|
173
|
+
def chunk(self, chunk: str | bytes) -> None:
|
|
174
|
+
text = self._decoder.decode(chunk) if isinstance(chunk, bytes) else chunk
|
|
175
|
+
if not isinstance(text, str):
|
|
176
|
+
return
|
|
177
|
+
# Do not retain an oversized event or parse its suffix as a new event.
|
|
178
|
+
for piece in text.splitlines(keepends=True):
|
|
179
|
+
self._size += len(piece)
|
|
180
|
+
if self._size > _MAX_FRAME:
|
|
181
|
+
self._observation_lost = True
|
|
182
|
+
self._dropping = True
|
|
183
|
+
self._buffer = ""
|
|
184
|
+
self._data.clear()
|
|
185
|
+
if not self._dropping:
|
|
186
|
+
self._buffer += piece
|
|
187
|
+
if piece.endswith("\n"):
|
|
188
|
+
line = self._buffer.rstrip("\r\n")
|
|
189
|
+
if self._dropping:
|
|
190
|
+
# A blank physical line terminates the dropped SSE frame.
|
|
191
|
+
if piece in {"\n", "\r\n"}:
|
|
192
|
+
self._reset()
|
|
193
|
+
elif not line:
|
|
194
|
+
self._frame()
|
|
195
|
+
self._reset()
|
|
196
|
+
elif line.startswith("data:"):
|
|
197
|
+
self._data.append(line[5:].lstrip(" "))
|
|
198
|
+
self._buffer = ""
|
|
199
|
+
|
|
200
|
+
def _reset(self) -> None:
|
|
201
|
+
self._buffer = ""
|
|
202
|
+
self._data.clear()
|
|
203
|
+
self._size = 0
|
|
204
|
+
self._dropping = False
|
|
205
|
+
|
|
206
|
+
def _frame(self) -> None:
|
|
207
|
+
data = "\n".join(self._data).strip()
|
|
208
|
+
if data == "[DONE]":
|
|
209
|
+
self._terminal = True
|
|
210
|
+
elif data:
|
|
211
|
+
try:
|
|
212
|
+
self.payload(json.loads(data))
|
|
213
|
+
except (ValueError, TypeError):
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
def finish(self) -> None:
|
|
217
|
+
if not self._dropping:
|
|
218
|
+
# Native Pipe wrappers can end with an unterminated [DONE] line.
|
|
219
|
+
if self._buffer.startswith("data:"):
|
|
220
|
+
self._data.append(self._buffer[5:].strip())
|
|
221
|
+
self._frame()
|
|
222
|
+
self._reset()
|
|
223
|
+
if self._observation_lost:
|
|
224
|
+
self.span.set_attribute("openwebui.observation_lost", True)
|
|
225
|
+
for key, count in self.usage.items():
|
|
226
|
+
self.span.set_attribute(f"gen_ai.usage.{key}_tokens", count)
|
|
227
|
+
legacy = "prompt" if key == "input" else "completion"
|
|
228
|
+
self.span.set_attribute(f"llm.token_count.{legacy}", count)
|
|
229
|
+
if len(self.usage) == 2 and sum(self.usage.values()) < 2**63:
|
|
230
|
+
self.span.set_attribute("llm.token_count.total", sum(self.usage.values()))
|
|
231
|
+
if not self.usage:
|
|
232
|
+
self.span.set_attribute("gen_ai.usage.unavailable", True)
|
|
233
|
+
if self.capture_content:
|
|
234
|
+
self.span.set_attribute("output.value", self.output)
|
traceai_openwebui/cli.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Generate Open WebUI's supported Function import file from the installed package."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def function_document() -> dict:
|
|
11
|
+
source = f'''"""
|
|
12
|
+
title: Traced model
|
|
13
|
+
version: {__version__}
|
|
14
|
+
license: Apache-2.0
|
|
15
|
+
required_open_webui_version: 0.11.4
|
|
16
|
+
requirements: traceAI-openwebui=={__version__}
|
|
17
|
+
"""
|
|
18
|
+
from traceai_openwebui import Pipe as _Pipe
|
|
19
|
+
|
|
20
|
+
class Pipe(_Pipe):
|
|
21
|
+
pass
|
|
22
|
+
'''
|
|
23
|
+
return {
|
|
24
|
+
"id": "traceai_openwebui",
|
|
25
|
+
"name": "Traced model",
|
|
26
|
+
"type": "pipe",
|
|
27
|
+
"content": source,
|
|
28
|
+
"meta": {"description": "Trace individual native model calls in Future AGI"},
|
|
29
|
+
"is_active": False,
|
|
30
|
+
"is_global": False,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main() -> None:
|
|
35
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
36
|
+
parser.add_argument("command", choices=["export-function"])
|
|
37
|
+
parser.add_argument("--output", type=Path, required=True)
|
|
38
|
+
args = parser.parse_args()
|
|
39
|
+
# Never overwrite a user's existing Function export or configuration.
|
|
40
|
+
with args.output.open("x", encoding="utf-8") as handle:
|
|
41
|
+
json.dump([function_document()], handle, indent=2)
|
|
42
|
+
handle.write("\n")
|
|
@@ -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,8 @@
|
|
|
1
|
+
traceai_openwebui/__init__.py,sha256=wNI4yC38fRD0uMOnMs-fk6nsZ6r55hRe2cabdfJa46g,14690
|
|
2
|
+
traceai_openwebui/_provider.py,sha256=qWwnfMBzdLqFRIb3551g2-QRw6WXt3WDO33g1q_t1nM,7254
|
|
3
|
+
traceai_openwebui/_response.py,sha256=lm6c1jmKDlbwf5rnq7l__h5sw4VwQ0BPvXKa7fm9khw,9068
|
|
4
|
+
traceai_openwebui/cli.py,sha256=eESHASz89dn69pt2ozuCH7O9vIiOmkvKpnz6jwp1Mc0,1187
|
|
5
|
+
traceai_openwebui-0.1.0.dist-info/METADATA,sha256=lAbL_Z2BZIFk2uTpucWxBO_syz2eh8Z1mUXRB7BDXXU,8867
|
|
6
|
+
traceai_openwebui-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
traceai_openwebui-0.1.0.dist-info/entry_points.txt,sha256=jRV9AS90PhkYRPJ6nATcURjIugXksswLtgNVS2hEhCE,65
|
|
8
|
+
traceai_openwebui-0.1.0.dist-info/RECORD,,
|